diff --git a/workspaces/boost/.changeset/connector-utils-initial.md b/workspaces/boost/.changeset/connector-utils-initial.md new file mode 100644 index 00000000000..33c9d55d281 --- /dev/null +++ b/workspaces/boost/.changeset/connector-utils-initial.md @@ -0,0 +1,5 @@ +--- +'@red-hat-developer-hub/backstage-plugin-boost-connector-utils': minor +--- + +Initial release of shared utilities for Boost AI catalog connector entity providers. Provides CA bundle resolution, fault isolation wrappers, enable/disable config guards, and startup validation for air-gapped deployments. diff --git a/workspaces/boost/examples/app-config.connectors.yaml b/workspaces/boost/examples/app-config.connectors.yaml new file mode 100644 index 00000000000..ae1462d9326 --- /dev/null +++ b/workspaces/boost/examples/app-config.connectors.yaml @@ -0,0 +1,162 @@ +# Reference App-Config for AI Catalog Connectors +# +# This file demonstrates the configurable fields for all three AI catalog +# connectors (MCP Registry, RHOAI MCP Catalog, OCI Skill Registry). +# Copy the relevant sections into your app-config.yaml and adjust values +# for your deployment environment. +# +# Config root: ai-catalog.providers. +# Each connector reads its own Config subtree — shared utilities do not +# hard-code config paths. +# +# See also: workspaces/boost/plugins/boost-connector-utils/README.md + +# -------------------------------------------------------------------------- +# Internet-Connected Deployment (default) +# -------------------------------------------------------------------------- + +ai-catalog: + providers: + # --- MCP Registry Connector --- + mcpRegistry: + # Whether this connector is registered at startup (default: true) + enabled: true + + # MCP Registry endpoint URL (required, must be HTTPS) + # Falls back to public registry.modelcontextprotocol.io when omitted + endpoint: https://registry.modelcontextprotocol.io + + # TLS configuration for custom CA bundles (optional) + # Use when the registry has a self-signed or internal CA certificate + tls: + # Option 1: Path to a PEM file mounted from a K8s Secret/ConfigMap + # caFile: /etc/ssl/certs/custom-ca-bundle.crt + + # Option 2: PEM content from an environment variable + # The env var is populated by K8s via Secret mount + # caSecret: + # $env: MCP_REGISTRY_CA_BUNDLE + + # Registry credentials via mounted K8s Secret (optional) + # Values MUST use $env references — plaintext is rejected at startup + auth: + token: + $env: MCP_REGISTRY_TOKEN + + # Sync schedule (optional) + schedule: + frequency: { minutes: 30 } + timeout: { minutes: 10 } + + # --- RHOAI MCP Catalog Connector --- + rhoai: + mcpCatalog: + # Whether this connector is registered at startup (default: true) + enabled: true + + # Cross-cluster RHOAI MCP catalog API endpoint (required, must be HTTPS) + endpoint: https://mcp-catalog.rhoai-cluster.example.com + + # TLS configuration for custom CA bundles (optional) + tls: + # Path to a PEM file mounted from a K8s Secret/ConfigMap + caFile: /etc/rhdh/ca-bundles/rhoai-ca.pem + + # RHOAI credentials via mounted K8s Secret (required) + # Values MUST use $env references — plaintext is rejected at startup + auth: + clientId: + $env: RHOAI_CLIENT_ID + clientSecret: + $env: RHOAI_CLIENT_SECRET + + # Sync schedule (optional) + schedule: + frequency: { minutes: 15 } + timeout: { minutes: 5 } + + # --- OCI Skill Registry Connector --- + ociSkill: + # Whether this connector is registered at startup (default: true) + enabled: true + + # Multiple OCI registries can be configured + registries: + - # Public or internal OCI registry URL (required, must be HTTPS) + url: https://quay.io + # OCI namespace containing skill artifacts + namespace: skills + # Path to K8s pull secret in Docker config.json format (optional) + pullSecretPath: /var/run/secrets/quay-pull-secret/.dockerconfigjson + + - url: https://harbor.internal.example.com + namespace: ai-assets + pullSecretPath: /var/run/secrets/harbor-pull-secret/.dockerconfigjson + # Per-registry TLS configuration + tls: + caFile: /etc/ssl/certs/harbor-ca.crt + + # Discovery settings (optional) + discovery: + # Parallel manifest fetch concurrency (default: 20) + concurrency: 20 + + # Sync schedule (optional) + schedule: + frequency: { minutes: 30 } + timeout: { minutes: 15 } + +# -------------------------------------------------------------------------- +# Air-Gapped Deployment Variant +# -------------------------------------------------------------------------- +# Use this variant when the RHDH instance has no internet access. +# All endpoints point to internal mirrors. No public endpoint traffic +# will occur. +# +# Uncomment and replace the section above with this configuration: +# +# ai-catalog: +# providers: +# # --- MCP Registry (internal mirror) --- +# mcpRegistry: +# enabled: true +# endpoint: https://registry.internal.example.com +# tls: +# caFile: /etc/ssl/certs/internal-ca-bundle.pem +# auth: +# token: +# $env: MCP_REGISTRY_TOKEN +# schedule: +# frequency: { minutes: 30 } +# timeout: { minutes: 10 } +# +# # --- RHOAI MCP Catalog (internal cluster) --- +# rhoai: +# mcpCatalog: +# enabled: true +# endpoint: https://mcp-catalog.rhoai.internal.example.com +# tls: +# caFile: /etc/ssl/certs/internal-ca-bundle.pem +# auth: +# clientId: +# $env: RHOAI_CLIENT_ID +# clientSecret: +# $env: RHOAI_CLIENT_SECRET +# schedule: +# frequency: { minutes: 15 } +# timeout: { minutes: 5 } +# +# # --- OCI Skill Registry (internal Harbor/Quay mirror) --- +# ociSkill: +# enabled: true +# registries: +# - url: https://harbor.internal.example.com +# namespace: ai-skills +# pullSecretPath: /var/run/secrets/harbor-pull-secret/.dockerconfigjson +# tls: +# caFile: /etc/ssl/certs/internal-ca-bundle.pem +# discovery: +# concurrency: 10 +# schedule: +# frequency: { minutes: 30 } +# timeout: { minutes: 15 } diff --git a/workspaces/boost/openspec/changes/connector-shared-infrastructure/design.md b/workspaces/boost/openspec/changes/connector-shared-infrastructure/design.md index 4723a42c24d..ee5176357aa 100644 --- a/workspaces/boost/openspec/changes/connector-shared-infrastructure/design.md +++ b/workspaces/boost/openspec/changes/connector-shared-infrastructure/design.md @@ -33,7 +33,7 @@ The shared infrastructure lives in a standalone utility package (`@red-hat-devel plugins/boost-connector-utils/ ├── package.json # @red-hat-developer-hub/backstage-plugin-boost-connector-utils ├── src/ -│ ├── index.ts # Exports: loadCaBundle, createHttpsAgent, createProviderWrapper, createSafeRefresh, isConnectorEnabled, ConnectorErrorContext +│ ├── index.ts # Exports: loadCaBundle, createHttpsAgent, createProviderWrapper, createSafeRefresh, classifyConnectorError, isConnectorEnabled, validateConnectorStartupConfig, ConnectorEntityProvider, ConnectorErrorContext, ValidateConnectorStartupConfigOptions │ ├── ca-bundle.ts # CA bundle loading logic │ ├── fault-isolation.ts # Provider wrapper with error handling │ └── config.ts # Enable/disable guard @@ -71,14 +71,17 @@ ai-catalog: **Function signature:** ```typescript -function loadCaBundle(connectorConfig: Config): Buffer | undefined; +function loadCaBundle( + connectorConfig: Config, + logger: LoggerService, +): Buffer | undefined; ``` The caller passes the Config subtree that contains the `tls` block. This allows each connector to resolve its own config nesting before calling the shared utility: -- MCP Registry: `loadCaBundle(config.getConfig('ai-catalog.providers.mcpRegistry'))` -- RHOAI MCP Catalog: `loadCaBundle(config.getConfig('ai-catalog.providers.rhoai.mcpCatalog'))` -- OCI per-registry: `loadCaBundle(registryConfig)` where `registryConfig` is the per-registry Config node +- MCP Registry: `loadCaBundle(config.getConfig('ai-catalog.providers.mcpRegistry'), logger)` +- RHOAI MCP Catalog: `loadCaBundle(config.getConfig('ai-catalog.providers.rhoai.mcpCatalog'), logger)` +- OCI per-registry: `loadCaBundle(registryConfig, logger)` where `registryConfig` is the per-registry Config node **Behavior:** @@ -94,7 +97,7 @@ The caller passes the Config subtree that contains the `tls` block. This allows ```typescript const connectorConfig = config.getConfig('ai-catalog.providers.mcpRegistry'); -const caBundle = loadCaBundle(connectorConfig); +const caBundle = loadCaBundle(connectorConfig, logger); const agent = caBundle ? new https.Agent({ ca: caBundle }) : undefined; const client = axios.create({ httpsAgent: agent }); @@ -121,12 +124,13 @@ Backstage already provides entity data isolation per provider via entity buckets ```typescript // In boost-connector-utils/src/fault-isolation.ts export function createProviderWrapper( - provider: EntityProvider, + provider: ConnectorEntityProvider, logger: LoggerService, -): EntityProvider { + ctx?: { endpoint?: string }, +): ConnectorEntityProvider { return { getProviderName: () => provider.getProviderName(), - async connect(connection: EntityProviderConnection): Promise { + async connect(connection: unknown): Promise { try { await provider.connect(connection); } catch (error) { diff --git a/workspaces/boost/openspec/changes/connector-shared-infrastructure/proposal.md b/workspaces/boost/openspec/changes/connector-shared-infrastructure/proposal.md index dcab4296de5..0e918d9427f 100644 --- a/workspaces/boost/openspec/changes/connector-shared-infrastructure/proposal.md +++ b/workspaces/boost/openspec/changes/connector-shared-infrastructure/proposal.md @@ -10,8 +10,8 @@ Duplicating CA/TLS handling, error logging, and enable/disable config across eac ### CA Bundle Resolution Utility -- `loadCaBundle(connectorConfig: Config): Buffer | undefined` function — caller passes the Config subtree containing the `tls` block -- Caller resolves config nesting before calling: e.g., `config.getConfig('ai-catalog.providers.mcpRegistry')` for MCP, `config.getConfig('ai-catalog.providers.rhoai.mcpCatalog')` for RHOAI, per-registry Config node for OCI +- `loadCaBundle(connectorConfig: Config, logger: LoggerService): Buffer | undefined` function — caller passes the Config subtree containing the `tls` block +- Caller resolves config nesting before calling: e.g., `config.getConfig('ai-catalog.providers.mcpRegistry'), logger` for MCP, `config.getConfig('ai-catalog.providers.rhoai.mcpCatalog'), logger` for RHOAI, per-registry Config node for OCI - Reads CA bundles from K8s Secret/ConfigMap mounts or direct file paths - Creates `https.Agent` with custom CA for HTTP client injection - Handles missing/invalid CA gracefully: log warning, return undefined, don't crash provider diff --git a/workspaces/boost/openspec/changes/connector-shared-infrastructure/tasks.md b/workspaces/boost/openspec/changes/connector-shared-infrastructure/tasks.md index 5499367383e..0bdb4ff0ffc 100644 --- a/workspaces/boost/openspec/changes/connector-shared-infrastructure/tasks.md +++ b/workspaces/boost/openspec/changes/connector-shared-infrastructure/tasks.md @@ -3,7 +3,7 @@ ## 1. CA Bundle Resolution Utility (P0) — RHIDP-15329 - [ ] 1.1 Create `@red-hat-developer-hub/backstage-plugin-boost-connector-utils` package with `package.json`, TypeScript config, and README -- [ ] 1.2 Define `loadCaBundle(connectorConfig: Config): Buffer | undefined` function signature — caller passes the Config subtree containing the `tls` block +- [ ] 1.2 Define `loadCaBundle(connectorConfig: Config, logger: LoggerService): Buffer | undefined` function signature — caller passes the Config subtree containing the `tls` block - [ ] 1.3 Implement caFile resolution — read CA from `tls.caFile` within the provided Config subtree - [ ] 1.4 Implement caSecret resolution — read CA from `tls.caSecret.$env` within the provided Config subtree - [ ] 1.5 Add per-connector config isolation — each connector resolves its own Config nesting before calling `loadCaBundle()` (e.g., MCP passes `config.getConfig('ai-catalog.providers.mcpRegistry')`, RHOAI passes `config.getConfig('ai-catalog.providers.rhoai.mcpCatalog')`, OCI passes per-registry Config node) diff --git a/workspaces/boost/plugins/boost-backend/package.json b/workspaces/boost/plugins/boost-backend/package.json index f90251515bc..7c4578b70da 100644 --- a/workspaces/boost/plugins/boost-backend/package.json +++ b/workspaces/boost/plugins/boost-backend/package.json @@ -16,6 +16,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/plugins/boost-common/package.json b/workspaces/boost/plugins/boost-common/package.json index f7d92c09fe5..56a06e6e933 100644 --- a/workspaces/boost/plugins/boost-common/package.json +++ b/workspaces/boost/plugins/boost-common/package.json @@ -19,6 +19,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/plugins/boost-connector-utils/.eslintrc.js b/workspaces/boost/plugins/boost-connector-utils/.eslintrc.js new file mode 100644 index 00000000000..e2a53a6ad28 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/workspaces/boost/plugins/boost-connector-utils/README.md b/workspaces/boost/plugins/boost-connector-utils/README.md new file mode 100644 index 00000000000..dbd34867aa5 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/README.md @@ -0,0 +1,193 @@ +# @red-hat-developer-hub/backstage-plugin-boost-connector-utils + +Shared utilities for Boost AI catalog connector entity providers. Provides: + +- **CA bundle resolution** — load custom CA certificates from file paths or `$env` references for air-gapped HTTPS +- **Fault isolation** — wrap entity providers to catch crashes without taking down the catalog backend +- **Enable/disable** — guard connector registration based on config +- **Startup validation** — reject plaintext credentials and invalid endpoint URLs at startup + +## Installation + +```bash +yarn add @red-hat-developer-hub/backstage-plugin-boost-connector-utils +``` + +## Usage + +### CA Bundle Resolution + +Each connector resolves its own Config subtree before calling `loadCaBundle()`: + +```typescript +import { + loadCaBundle, + createHttpsAgent, +} from '@red-hat-developer-hub/backstage-plugin-boost-connector-utils'; + +// In your connector's init(): +const connectorConfig = config.getConfig('ai-catalog.providers.mcpRegistry'); +const caBundle = loadCaBundle(connectorConfig, logger); +const agent = createHttpsAgent(caBundle); + +// Use the agent with your HTTP client +const client = axios.create({ httpsAgent: agent }); +``` + +**Config format:** + +```yaml +ai-catalog: + providers: + mcpRegistry: + tls: + # Option 1: Direct file path (K8s Secret mounted as volume) + caFile: /etc/ssl/certs/custom-ca-bundle.pem + + # Option 2: K8s Secret reference (resolved via $env pattern) + caSecret: + $env: MCP_REGISTRY_CA_BUNDLE # Env var containing PEM content +``` + +**Behavior:** + +- Missing file → logs WARN, returns `undefined` (connector continues with system CA) +- Invalid PEM → logs ERROR, returns `undefined` +- Certificate chains (multiple concatenated PEM blocks) → returned as-is +- No `tls` block → returns `undefined` (uses system CA) +- Expired certificates are **not** checked at load time — they surface at TLS handshake time and are caught by the fault isolation wrapper + +> **Note:** For process-wide CA trust, set `NODE_EXTRA_CA_CERTS` in the container environment. This is outside the scope of per-connector config. + +### Fault Isolation + +Wrap providers to prevent unhandled rejections from crashing the Node.js process: + +```typescript +import { + createProviderWrapper, + createSafeRefresh, +} from '@red-hat-developer-hub/backstage-plugin-boost-connector-utils'; + +// Wrap the provider before registering it +const rawProvider = new McpRegistryEntityProvider(config, logger); +const provider = createProviderWrapper(rawProvider, logger, { + endpoint: 'https://registry.example.com', +}); +catalog.addEntityProvider(provider); + +// Wrap refresh callbacks for scheduled tasks +const safeRefresh = createSafeRefresh( + () => provider.refresh(), + 'mcpRegistry', + logger, + { endpoint: 'https://registry.example.com' }, +); +scheduler.scheduleTask({ fn: safeRefresh, frequency: { minutes: 10 } }); +``` + +**Error classification:** + +The `classifyConnectorError(error)` function classifies errors as retryable or non-retryable: + +- **Retryable (transient):** `ECONNREFUSED`, `ECONNRESET`, `ETIMEDOUT`, `EPIPE`, `EAI_AGAIN`, HTTP 429/500/502/503/504 +- **Non-retryable (fatal):** HTTP 400/401/403/404, `TypeError`, `SyntaxError`, `ZodError`, TLS certificate errors + +Structured error context logged on failure: + +```json +{ + "connectorId": "mcpRegistry", + "endpoint": "https://registry.example.com", + "errorType": "FetchError", + "errorMessage": "request to https://registry.example.com failed", + "retryable": true +} +``` + +### Enable/Disable Pattern + +Guard connector registration in your backend module `init()`: + +```typescript +import { + isConnectorEnabled, + validateConnectorStartupConfig, +} from '@red-hat-developer-hub/backstage-plugin-boost-connector-utils'; + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'mcp-registry', + register(env) { + env.registerInit({ + deps: { + catalog: catalogProcessingExtensionPoint, + config: coreServices.rootConfig, + logger: coreServices.logger, + }, + async init({ catalog, config, logger }) { + const connectorConfig = config.getConfig( + 'ai-catalog.providers.mcpRegistry', + ); + + if (!isConnectorEnabled(connectorConfig)) { + logger.info('MCP Registry connector is disabled'); + return; + } + + // Validate credentials and endpoint before registering + validateConnectorStartupConfig(connectorConfig, { + credentialFields: ['auth.token'], + endpointField: 'endpoint', + }); + + const provider = new McpRegistryEntityProvider(config, logger); + catalog.addEntityProvider(createProviderWrapper(provider, logger)); + }, + }); + }, +}); +``` + +**Config:** + +```yaml +ai-catalog: + providers: + mcpRegistry: + enabled: true # Default: true if omitted + endpoint: https://registry.example.com +``` + +### Startup Validation + +`validateConnectorStartupConfig()` validates that: + +1. Credential fields are non-empty (should use `{ $env: "ENV_VAR_NAME" }` backed by mounted K8s Secrets) +2. Endpoint URL is valid HTTPS + +```typescript +// Throws descriptive error on first validation failure +validateConnectorStartupConfig(connectorConfig, { + credentialFields: ['auth.clientId', 'auth.clientSecret'], + endpointField: 'endpoint', +}); +``` + +## API Reference + +| Export | Description | +| ----------------------------------------------- | -------------------------------------------- | +| `loadCaBundle(config, logger)` | Load CA bundle from connector config subtree | +| `createHttpsAgent(caBundle?)` | Create `https.Agent` with custom CA | +| `createProviderWrapper(provider, logger, ctx?)` | Wrap entity provider with fault isolation | +| `createSafeRefresh(fn, id, logger, ctx?)` | Wrap refresh callback with fault isolation | +| `classifyConnectorError(error)` | Classify error as retryable/non-retryable | +| `isConnectorEnabled(config)` | Check if connector is enabled via config | +| `validateConnectorStartupConfig(config, opts)` | Validate credentials and endpoint at startup | +| `ConnectorErrorContext` | Interface for structured error context | +| `ValidateConnectorStartupConfigOptions` | Options for startup validation | + +## Reference Configuration + +See [`workspaces/boost/examples/app-config.connectors.yaml`](../../examples/app-config.connectors.yaml) for a complete reference configuration with all three connectors and an air-gapped deployment variant. diff --git a/workspaces/boost/plugins/boost-connector-utils/package.json b/workspaces/boost/plugins/boost-connector-utils/package.json new file mode 100644 index 00000000000..13f671dfc29 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/package.json @@ -0,0 +1,54 @@ +{ + "name": "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", + "version": "0.1.0", + "license": "Apache-2.0", + "description": "Shared utilities for Boost AI catalog connector entity providers: CA bundle resolution, fault isolation, enable/disable, and startup validation", + "main": "src/index.ts", + "types": "src/index.ts", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library", + "pluginId": "boost", + "pluginPackage": "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", + "pluginPackages": [ + "@red-hat-developer-hub/backstage-plugin-boost", + "@red-hat-developer-hub/backstage-plugin-boost-backend", + "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", + "@red-hat-developer-hub/backstage-plugin-boost-node", + "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", + "@red-hat-developer-hub/backstage-plugin-boost-toolscope" + ] + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.9.2", + "@backstage/config": "^1.3.8" + }, + "devDependencies": { + "@backstage/cli": "^0.36.3" + }, + "sideEffects": false, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/redhat-developer/rhdh-plugins.git", + "directory": "workspaces/boost/plugins/boost-connector-utils" + }, + "homepage": "https://red.ht/rhdh", + "bugs": "https://github.com/redhat-developer/rhdh-plugins/issues" +} diff --git a/workspaces/boost/plugins/boost-connector-utils/report.api.md b/workspaces/boost/plugins/boost-connector-utils/report.api.md new file mode 100644 index 00000000000..2b360d721b2 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/report.api.md @@ -0,0 +1,74 @@ +## API Report File for "@red-hat-developer-hub/backstage-plugin-boost-connector-utils" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { Agent } from 'node:https'; +import type { Config } from '@backstage/config'; +import type { LoggerService } from '@backstage/backend-plugin-api'; + +// @public +export function classifyConnectorError(error: unknown): boolean; + +// @public +export interface ConnectorEntityProvider { + connect(connection: unknown): Promise; + getProviderName(): string; +} + +// @public +export interface ConnectorErrorContext { + [key: string]: string | boolean | undefined; + connectorId: string; + endpoint?: string; + errorMessage: string; + errorType: string; + nextRetryAt?: string; + retryable: boolean; +} + +// @public +export function createHttpsAgent(caBundle?: Buffer): Agent | undefined; + +// @public +export function createProviderWrapper( + provider: ConnectorEntityProvider, + logger: LoggerService, + ctx?: FaultIsolationContext, +): ConnectorEntityProvider; + +// @public +export function createSafeRefresh( + refreshFn: () => Promise, + connectorId: string, + logger: LoggerService, + ctx?: FaultIsolationContext, +): () => Promise; + +// @public +export interface FaultIsolationContext { + endpoint?: string; + nextRetryAt?: string; +} + +// @public +export function isConnectorEnabled(connectorConfig: Config): boolean; + +// @public +export function loadCaBundle( + connectorConfig: Config, + logger: LoggerService, +): Buffer | undefined; + +// @public +export function validateConnectorStartupConfig( + connectorConfig: Config, + options: ValidateConnectorStartupConfigOptions, +): void; + +// @public +export interface ValidateConnectorStartupConfigOptions { + credentialFields: string[]; + endpointField?: string; +} +``` diff --git a/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.test.ts b/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.test.ts new file mode 100644 index 00000000000..adb1044746b --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.test.ts @@ -0,0 +1,202 @@ +/* + * 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 * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { ConfigReader } from '@backstage/config'; +import { loadCaBundle, createHttpsAgent } from './ca-bundle'; + +// Valid PEM certificate for testing +const VALID_PEM = `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJALRiMLAh0GRFMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDFyP0DJhJi8XwFI5fDiX7g +TzP2fjnbN3UNe0E5lPUBbx3mKKL6XxOaxf1C1ZP0NeW4jMqUPP8AByEJrq+7JikC +AwEAAaNTMFEwHQYDVR0OBBYEFBkIra39eRYFI1MzRITO3RVjIiJbMB8GA1UdIwQY +MBaAFBkIra39eRYFI1MzRITO3RVjIiJbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADQQBGIjYqgRJHJBD7KEz1YLzUhVJxZnMHQP0sT4OI+/+3g1CLDYJ +v6PTXLV5V3LfTxH+8cITsh8R+C/PN5MVyNg/ +-----END CERTIFICATE-----`; + +const SECOND_PEM = `-----BEGIN CERTIFICATE----- +MIIBkTCB+wIJALRiMLAh0GRFMA0GCSqGSIb3DQEBCwUAMBExDzANBgNVBAMMBnRl +c3RjYTAeFw0yMzAxMDEwMDAwMDBaFw0yNDAxMDEwMDAwMDBaMBExDzANBgNVBAMM +BnRlc3RjYTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQDFyP0DJhJi8XwFI5fDiX7g +TzP2fjnbN3UNe0E5lPUBbx3mKKL6XxOaxf1C1ZP0NeW4jMqUPP8AByEJrq+7JikC +AwEAAaNTMFEwHQYDVR0OBBYEFBkIra39eRYFI1MzRITO3RVjIiJbMB8GA1UdIwQY +MBaAFBkIra39eRYFI1MzRITO3RVjIiJbMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZI +hvcNAQELBQADQQBGIjYqgRJHJBD7KEz1YLzUhVJxZnMHQP0sT4OI+/+3g1CLDYJ +v6PTXLV5V3LfTxH+8cITsh8R+C/PN5MVyNg/ +-----END CERTIFICATE-----`; + +function createMockLogger() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +describe('loadCaBundle', () => { + let tmpDir: string; + let logger: ReturnType; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ca-bundle-test-')); + logger = createMockLogger(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns undefined when no tls block is configured', () => { + const config = new ConfigReader({}); + expect(loadCaBundle(config, logger)).toBeUndefined(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('loads CA from file path (tls.caFile)', () => { + const caPath = path.join(tmpDir, 'ca.pem'); + fs.writeFileSync(caPath, VALID_PEM); + + const config = new ConfigReader({ tls: { caFile: caPath } }); + const result = loadCaBundle(config, logger); + + expect(result).toBeInstanceOf(Buffer); + expect(result!.toString('utf-8')).toContain('-----BEGIN CERTIFICATE-----'); + }); + + it('loads CA from environment variable (tls.caSecret resolved via $env)', () => { + // In production, Backstage resolves { $env: "VAR" } at config + // loading time. By the time our code sees it, the value is a + // plain string containing the PEM content. + const config = new ConfigReader({ + tls: { caSecret: VALID_PEM }, + }); + const result = loadCaBundle(config, logger); + + expect(result).toBeInstanceOf(Buffer); + expect(result!.toString('utf-8')).toContain('-----BEGIN CERTIFICATE-----'); + }); + + it('returns undefined and logs WARN for missing CA file', () => { + const missingPath = path.join(tmpDir, 'missing.pem'); + const config = new ConfigReader({ tls: { caFile: missingPath } }); + + const result = loadCaBundle(config, logger); + + expect(result).toBeUndefined(); + expect(logger.warn).toHaveBeenCalledWith( + 'CA file not found', + expect.objectContaining({ caFile: missingPath }), + ); + }); + + it('returns undefined and logs ERROR for invalid PEM data', () => { + const invalidPath = path.join(tmpDir, 'invalid.pem'); + fs.writeFileSync(invalidPath, 'this is not a PEM file'); + + const config = new ConfigReader({ tls: { caFile: invalidPath } }); + const result = loadCaBundle(config, logger); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + 'CA file does not contain valid PEM data', + expect.objectContaining({ caFile: invalidPath }), + ); + }); + + it('supports CA certificate chains (multiple PEM blocks)', () => { + const chainPath = path.join(tmpDir, 'chain.pem'); + const chain = `${VALID_PEM}\n${SECOND_PEM}`; + fs.writeFileSync(chainPath, chain); + + const config = new ConfigReader({ tls: { caFile: chainPath } }); + const result = loadCaBundle(config, logger); + + expect(result).toBeInstanceOf(Buffer); + const content = result!.toString('utf-8'); + // Should contain both certificates + const matches = content.match(/-----BEGIN CERTIFICATE-----/g); + expect(matches).toHaveLength(2); + }); + + it('isolates per-connector CA bundles', () => { + const caPathA = path.join(tmpDir, 'ca-a.pem'); + const caPathB = path.join(tmpDir, 'ca-b.pem'); + fs.writeFileSync(caPathA, VALID_PEM); + fs.writeFileSync(caPathB, SECOND_PEM); + + const configA = new ConfigReader({ tls: { caFile: caPathA } }); + const configB = new ConfigReader({ tls: { caFile: caPathB } }); + + const resultA = loadCaBundle(configA, logger); + const resultB = loadCaBundle(configB, logger); + + expect(resultA).toBeInstanceOf(Buffer); + expect(resultB).toBeInstanceOf(Buffer); + // Each gets its own file content + expect(resultA!.toString('utf-8')).toEqual( + fs.readFileSync(caPathA, 'utf-8'), + ); + expect(resultB!.toString('utf-8')).toEqual( + fs.readFileSync(caPathB, 'utf-8'), + ); + }); + + it('returns undefined for empty caSecret value', () => { + // Simulate a resolved $env that pointed to an empty env var. + // Backstage's ConfigReader rejects empty strings, so this + // scenario results in the caSecret key being absent or + // erroring. We test the code path where it is simply not set. + const config = new ConfigReader({ tls: {} }); + const result = loadCaBundle(config, logger); + + expect(result).toBeUndefined(); + }); + + it('returns undefined for caSecret with invalid PEM data', () => { + // Simulate a resolved $env value containing non-PEM data + const config = new ConfigReader({ + tls: { caSecret: 'not-pem-data' }, + }); + const result = loadCaBundle(config, logger); + + expect(result).toBeUndefined(); + expect(logger.error).toHaveBeenCalledWith( + 'CA secret does not contain valid PEM data', + ); + }); +}); + +describe('createHttpsAgent', () => { + it('returns undefined when no CA bundle is provided', () => { + expect(createHttpsAgent(undefined)).toBeUndefined(); + }); + + it('creates an https.Agent with the provided CA bundle', () => { + const ca = Buffer.from(VALID_PEM, 'utf-8'); + const agent = createHttpsAgent(ca); + + expect(agent).toBeDefined(); + expect(agent!.options.ca).toEqual(ca); + }); +}); diff --git a/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.ts b/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.ts new file mode 100644 index 00000000000..f1f3c521b9c --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/ca-bundle.ts @@ -0,0 +1,144 @@ +/* + * 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 { readFileSync } from 'node:fs'; +import { Agent as HttpsAgent } from 'node:https'; +import type { Config } from '@backstage/config'; +import type { LoggerService } from '@backstage/backend-plugin-api'; + +const PEM_HEADER = '-----BEGIN CERTIFICATE-----'; + +/** + * Load a CA bundle from the connector's Config subtree. + * + * Reads the CA from either `tls.caFile` (file path) or + * `tls.caSecret.$env` (environment variable containing PEM content) + * within the provided Config subtree. + * + * @param connectorConfig - The connector's Config subtree containing + * the `tls` block. + * @param logger - Backstage LoggerService for structured logging. + * @returns A Buffer containing the PEM-encoded CA certificate(s), + * or `undefined` if no CA is configured or an error occurs. + * + * @public + */ +export function loadCaBundle( + connectorConfig: Config, + logger: LoggerService, +): Buffer | undefined { + let tlsConfig: Config; + try { + tlsConfig = connectorConfig.getConfig('tls'); + } catch { + // No tls block configured — use system CA bundle + return undefined; + } + + // Option 1: tls.caFile — read from file path + const caFile = tlsConfig.getOptionalString('caFile'); + if (caFile) { + return loadCaFromFile(caFile, logger); + } + + // Option 2: tls.caSecret.$env — read PEM from environment variable + const caSecret = tlsConfig.getOptionalString('caSecret'); + if (caSecret) { + return loadCaFromEnvValue(caSecret, logger); + } + + return undefined; +} + +/** + * Read a CA bundle from a file path. + * + * @internal + */ +function loadCaFromFile( + filePath: string, + logger: LoggerService, +): Buffer | undefined { + try { + const content = readFileSync(filePath); + if (!isValidPem(content)) { + logger.error('CA file does not contain valid PEM data', { + caFile: filePath, + }); + return undefined; + } + return content; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === 'ENOENT') { + logger.warn('CA file not found', { caFile: filePath }); + } else { + logger.error('Failed to read CA file', { + caFile: filePath, + errorMessage: err.message, + }); + } + return undefined; + } +} + +/** + * Parse PEM content from a resolved environment variable value. + * + * @internal + */ +function loadCaFromEnvValue( + pemContent: string, + logger: LoggerService, +): Buffer | undefined { + if (!pemContent.trim()) { + logger.warn('CA secret environment variable is empty'); + return undefined; + } + + const buf = Buffer.from(pemContent, 'utf-8'); + if (!isValidPem(buf)) { + logger.error('CA secret does not contain valid PEM data'); + return undefined; + } + return buf; +} + +/** + * Check whether a buffer contains at least one PEM certificate block. + * + * @internal + */ +function isValidPem(content: Buffer): boolean { + return content.toString('utf-8').includes(PEM_HEADER); +} + +/** + * Create an `https.Agent` configured with a custom CA bundle. + * + * @param caBundle - A Buffer containing PEM-encoded CA certificates, + * or `undefined` to use the system default. + * @returns An `https.Agent` with the custom CA, or `undefined` if + * no CA bundle was provided. + * + * @public + */ +export function createHttpsAgent(caBundle?: Buffer): HttpsAgent | undefined { + if (!caBundle) { + return undefined; + } + return new HttpsAgent({ ca: caBundle, rejectUnauthorized: true }); +} diff --git a/workspaces/boost/plugins/boost-connector-utils/src/config.test.ts b/workspaces/boost/plugins/boost-connector-utils/src/config.test.ts new file mode 100644 index 00000000000..5f17f611264 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/config.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { isConnectorEnabled, validateConnectorStartupConfig } from './config'; + +describe('isConnectorEnabled', () => { + it('returns true when enabled is true', () => { + const config = new ConfigReader({ enabled: true }); + expect(isConnectorEnabled(config)).toBe(true); + }); + + it('returns false when enabled is false', () => { + const config = new ConfigReader({ enabled: false }); + expect(isConnectorEnabled(config)).toBe(false); + }); + + it('returns true when enabled is omitted (default)', () => { + const config = new ConfigReader({}); + expect(isConnectorEnabled(config)).toBe(true); + }); + + it('returns true when enabled is omitted in a config with other fields', () => { + const config = new ConfigReader({ + endpoint: 'https://example.com', + }); + expect(isConnectorEnabled(config)).toBe(true); + }); +}); + +describe('validateConnectorStartupConfig', () => { + describe('endpoint validation', () => { + it('accepts a valid HTTPS URL', () => { + const config = new ConfigReader({ + endpoint: 'https://registry.example.com', + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: [], + endpointField: 'endpoint', + }), + ).not.toThrow(); + }); + + it('rejects an HTTP URL', () => { + const config = new ConfigReader({ + endpoint: 'http://registry.example.com', + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: [], + endpointField: 'endpoint', + }), + ).toThrow(/Must use HTTPS protocol/); + }); + + it('rejects an invalid URL', () => { + const config = new ConfigReader({ + endpoint: 'not-a-url', + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: [], + endpointField: 'endpoint', + }), + ).toThrow(/Must be a valid HTTPS URL/); + }); + + it('allows missing endpoint when field is optional', () => { + const config = new ConfigReader({}); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: [], + endpointField: 'endpoint', + }), + ).not.toThrow(); + }); + }); + + describe('credential validation', () => { + it('rejects empty credential values', () => { + const config = new ConfigReader({ + auth: { token: '' }, + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.token'], + }), + ).toThrow(/Credential field 'auth.token' is invalid/); + }); + + it('allows missing credential fields (optional)', () => { + const config = new ConfigReader({}); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.token'], + }), + ).not.toThrow(); + }); + + it('accepts non-empty credential values (resolved $env)', () => { + // In production, Backstage resolves { $env: "VAR" } at config + // loading time. By the time our code sees it, the value is a + // plain string. Simulate the resolved value directly. + const config = new ConfigReader({ + auth: { token: 'resolved-token-value' }, + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.token'], + }), + ).not.toThrow(); + }); + + it('validates multiple credential fields', () => { + const config = new ConfigReader({ + auth: { clientId: 'valid-id', clientSecret: '' }, + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.clientId', 'auth.clientSecret'], + }), + ).toThrow(/Credential field 'auth.clientSecret' is invalid/); + }); + }); + + describe('combined validation', () => { + it('validates both credentials and endpoint in one call', () => { + // Simulate resolved $env value + const config = new ConfigReader({ + endpoint: 'https://registry.example.com', + auth: { token: 'my-resolved-token' }, + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.token'], + endpointField: 'endpoint', + }), + ).not.toThrow(); + }); + + it('provides descriptive error with example', () => { + const config = new ConfigReader({ + auth: { token: '' }, + }); + expect(() => + validateConnectorStartupConfig(config, { + credentialFields: ['auth.token'], + }), + ).toThrow(/auth\.token.*\$env.*K8s Secrets/s); + }); + }); +}); diff --git a/workspaces/boost/plugins/boost-connector-utils/src/config.ts b/workspaces/boost/plugins/boost-connector-utils/src/config.ts new file mode 100644 index 00000000000..975d946ddd2 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/config.ts @@ -0,0 +1,133 @@ +/* + * 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 type { Config } from '@backstage/config'; +import type { ValidateConnectorStartupConfigOptions } from './types'; + +/** + * Check whether a connector is enabled via its Config subtree. + * + * Reads the `enabled` boolean from the provided Config subtree. + * Returns `true` if the field is omitted (default enabled). + * + * @param connectorConfig - The connector's Config subtree. + * @returns `true` if the connector should be registered, `false` otherwise. + * + * @public + */ +export function isConnectorEnabled(connectorConfig: Config): boolean { + return connectorConfig.getOptionalBoolean('enabled') ?? true; +} + +/** + * Validate connector startup configuration. Checks that credential + * fields use Backstage `$env` references (not plaintext) and that + * the endpoint URL is a valid HTTPS URL. + * + * Throws a descriptive error on the first validation failure, + * suitable for use in backend module `init()`. + * + * @param connectorConfig - The connector's Config subtree. + * @param options - Credential fields and optional endpoint field to validate. + * + * @public + */ +export function validateConnectorStartupConfig( + connectorConfig: Config, + options: ValidateConnectorStartupConfigOptions, +): void { + // Validate credential fields use $env references (not plaintext strings) + for (const field of options.credentialFields) { + validateCredentialField(connectorConfig, field); + } + + // Validate endpoint URL if specified + if (options.endpointField) { + validateEndpointField(connectorConfig, options.endpointField); + } +} + +/** + * Validate that a credential field is non-empty when present. + * + * Backstage resolves `$env` references at config-load time, so + * runtime code only sees the resolved string value. We validate + * that the value is present and non-empty; the deployer is expected + * to back credential fields with `{ $env: "ENV_VAR_NAME" }` mounted + * from K8s Secrets. + * + * @internal + */ +function validateCredentialField(config: Config, field: string): void { + // If the field is not present at all, skip validation — it may be optional + if (!config.has(field)) { + return; + } + + // Backstage's ConfigReader.getOptionalString() throws on empty + // strings and object values, so we catch and re-throw with a + // descriptive message pointing the deployer to $env usage. + try { + config.getOptionalString(field); + } catch { + throw new Error( + `Credential field '${field}' is invalid. ` + + `Use { $env: "ENV_VAR_NAME" } backed by mounted K8s Secrets. ` + + `Example: ${field}: { $env: "${toEnvVarName(field)}" }`, + ); + } +} + +/** + * Validate that an endpoint URL field contains a valid HTTPS URL. + * + * @internal + */ +function validateEndpointField(config: Config, field: string): void { + const value = config.getOptionalString(field); + if (value === undefined) { + return; + } + + let parsed: URL; + try { + parsed = new URL(value); + } catch { + throw new Error( + `Invalid ${field} '${value}'. Must be a valid HTTPS URL. ` + + `Example: https://registry.internal.example.com`, + ); + } + + if (parsed.protocol !== 'https:') { + throw new Error( + `Invalid ${field} '${value}'. Must use HTTPS protocol. ` + + `Example: https://registry.internal.example.com`, + ); + } +} + +/** + * Convert a dotted config field name to an environment variable name. + * + * @internal + */ +function toEnvVarName(field: string): string { + return field + .replaceAll('.', '_') + .replaceAll(/([a-z])([A-Z])/g, '$1_$2') + .toUpperCase(); +} diff --git a/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.test.ts b/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.test.ts new file mode 100644 index 00000000000..2703e73e22b --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.test.ts @@ -0,0 +1,341 @@ +/* + * 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 { + createProviderWrapper, + createSafeRefresh, + classifyConnectorError, +} from './fault-isolation'; + +function createMockLogger() { + return { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn().mockReturnThis(), + }; +} + +function createMockProvider( + name: string, + connectFn: (connection: unknown) => Promise = async () => {}, +) { + return { + getProviderName: () => name, + connect: connectFn, + }; +} + +describe('classifyConnectorError', () => { + it.each([ + ['ECONNREFUSED', 'Connection refused'], + ['ECONNRESET', 'Connection reset'], + ['ETIMEDOUT', 'Timed out'], + ])('classifies %s as retryable', (code, message) => { + const error = new Error(message); + (error as NodeJS.ErrnoException).code = code; + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies HTTP 503 as retryable', () => { + const error = new Error('Service Unavailable') as Error & { + status: number; + }; + error.status = 503; + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies HTTP 429 as retryable', () => { + const error = new Error('Too Many Requests') as Error & { + statusCode: number; + }; + error.statusCode = 429; + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies HTTP 401 as non-retryable', () => { + const error = new Error('Unauthorized') as Error & { status: number }; + error.status = 401; + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies HTTP 404 as non-retryable', () => { + const error = new Error('Not Found') as Error & { status: number }; + error.status = 404; + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies axios-shaped response.status 503 as retryable', () => { + const error = new Error('Request failed with status 503') as Error & { + response: { status: number }; + }; + error.response = { status: 503 }; + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies axios-shaped response.status 401 as non-retryable', () => { + const error = new Error('Request failed with status 401') as Error & { + response: { status: number }; + }; + error.response = { status: 401 }; + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies bare TypeError as non-retryable', () => { + const error = new TypeError('Invalid URL'); + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies TypeError with retryable cause.code as retryable (native fetch)', () => { + const cause = new Error('connect ECONNREFUSED 127.0.0.1:443'); + (cause as NodeJS.ErrnoException).code = 'ECONNREFUSED'; + const error = new TypeError('fetch failed', { cause }); + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies TypeError with ETIMEDOUT cause as retryable', () => { + const cause = new Error('connection timed out'); + (cause as NodeJS.ErrnoException).code = 'ETIMEDOUT'; + const error = new TypeError('fetch failed', { cause }); + expect(classifyConnectorError(error)).toBe(true); + }); + + it('classifies TypeError with non-retryable TLS cause as non-retryable', () => { + const cause = new Error('certificate has expired'); + (cause as NodeJS.ErrnoException).code = 'CERT_HAS_EXPIRED'; + const error = new TypeError('fetch failed', { cause }); + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies SyntaxError as non-retryable', () => { + const error = new SyntaxError('Unexpected token'); + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies CERT_HAS_EXPIRED as non-retryable', () => { + const error = new Error('certificate has expired'); + (error as NodeJS.ErrnoException).code = 'CERT_HAS_EXPIRED'; + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies UNABLE_TO_VERIFY_LEAF_SIGNATURE as non-retryable', () => { + const error = new Error('unable to verify leaf signature'); + (error as NodeJS.ErrnoException).code = 'UNABLE_TO_VERIFY_LEAF_SIGNATURE'; + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies unknown errors as non-retryable', () => { + const error = new Error('Something unexpected'); + expect(classifyConnectorError(error)).toBe(false); + }); + + it('classifies non-Error values as non-retryable', () => { + expect(classifyConnectorError('string error')).toBe(false); + expect(classifyConnectorError(null)).toBe(false); + }); +}); + +describe('createProviderWrapper', () => { + it('delegates getProviderName to the wrapped provider', () => { + const logger = createMockLogger(); + const provider = createMockProvider('mcpRegistry'); + const wrapped = createProviderWrapper(provider, logger); + + expect(wrapped.getProviderName()).toBe('mcpRegistry'); + }); + + it('delegates connect() to the wrapped provider', async () => { + const logger = createMockLogger(); + const connectFn = jest.fn().mockResolvedValue(undefined); + const provider = createMockProvider('mcpRegistry', connectFn); + const wrapped = createProviderWrapper(provider, logger); + + const mockConnection = {}; + await wrapped.connect(mockConnection as never); + + expect(connectFn).toHaveBeenCalledWith(mockConnection); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('catches connect() errors and logs structured error', async () => { + const logger = createMockLogger(); + const crashError = new Error('DNS resolution failure'); + (crashError as NodeJS.ErrnoException).code = 'EAI_AGAIN'; + const provider = createMockProvider('mcpRegistry', async () => { + throw crashError; + }); + + const wrapped = createProviderWrapper(provider, logger, { + endpoint: 'https://registry.example.com', + }); + // Should not throw + await wrapped.connect({} as never); + + expect(logger.error).toHaveBeenCalledWith( + 'Connector connect() failed', + expect.objectContaining({ + connectorId: 'mcpRegistry', + endpoint: 'https://registry.example.com', + errorType: 'Error', + errorMessage: 'DNS resolution failure', + retryable: true, + }), + ); + }); + + it('does not rethrow errors from connect()', async () => { + const logger = createMockLogger(); + const provider = createMockProvider('rhoai', async () => { + throw new Error('crash'); + }); + const wrapped = createProviderWrapper(provider, logger); + + // This should NOT throw + await expect(wrapped.connect({} as never)).resolves.toBeUndefined(); + }); + + it('includes nextRetryAt in error context when provided', async () => { + const logger = createMockLogger(); + const crashError = new Error('Connection refused'); + (crashError as NodeJS.ErrnoException).code = 'ECONNREFUSED'; + const provider = createMockProvider('mcpRegistry', async () => { + throw crashError; + }); + + const wrapped = createProviderWrapper(provider, logger, { + endpoint: 'https://registry.example.com', + nextRetryAt: '2025-01-01T00:05:00Z', + }); + await wrapped.connect({} as never); + + expect(logger.error).toHaveBeenCalledWith( + 'Connector connect() failed', + expect.objectContaining({ + connectorId: 'mcpRegistry', + retryable: true, + nextRetryAt: '2025-01-01T00:05:00Z', + }), + ); + }); + + it('omits nextRetryAt for non-retryable errors even if provided', async () => { + const logger = createMockLogger(); + const provider = createMockProvider('mcpRegistry', async () => { + throw new TypeError('Invalid URL'); + }); + + const wrapped = createProviderWrapper(provider, logger, { + endpoint: 'https://registry.example.com', + nextRetryAt: '2025-01-01T00:05:00Z', + }); + await wrapped.connect({} as never); + + const loggedCtx = (logger.error as jest.Mock).mock.calls[0][1]; + expect(loggedCtx.retryable).toBe(false); + expect(loggedCtx.nextRetryAt).toBeUndefined(); + }); + + it('allows multiple providers to fail independently', async () => { + const logger = createMockLogger(); + + const providerA = createMockProvider('mcpRegistry', async () => { + throw new Error('MCP crash'); + }); + const providerB = createMockProvider('rhoai', async () => { + throw new Error('RHOAI crash'); + }); + + const wrappedA = createProviderWrapper(providerA, logger); + const wrappedB = createProviderWrapper(providerB, logger); + + await wrappedA.connect({} as never); + await wrappedB.connect({} as never); + + expect(logger.error).toHaveBeenCalledTimes(2); + expect(logger.error).toHaveBeenCalledWith( + 'Connector connect() failed', + expect.objectContaining({ connectorId: 'mcpRegistry' }), + ); + expect(logger.error).toHaveBeenCalledWith( + 'Connector connect() failed', + expect.objectContaining({ connectorId: 'rhoai' }), + ); + }); +}); + +describe('createSafeRefresh', () => { + it('calls the wrapped refresh function', async () => { + const logger = createMockLogger(); + const refreshFn = jest.fn().mockResolvedValue(undefined); + const safeRefresh = createSafeRefresh(refreshFn, 'mcpRegistry', logger); + + await safeRefresh(); + + expect(refreshFn).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('catches refresh errors and logs structured error', async () => { + const logger = createMockLogger(); + const refreshFn = jest.fn().mockRejectedValue(new Error('Network timeout')); + const safeRefresh = createSafeRefresh(refreshFn, 'mcpRegistry', logger, { + endpoint: 'https://registry.example.com/api/v1/tools', + }); + + // Should not throw + await safeRefresh(); + + expect(logger.error).toHaveBeenCalledWith( + 'Connector refresh failed', + expect.objectContaining({ + connectorId: 'mcpRegistry', + endpoint: 'https://registry.example.com/api/v1/tools', + errorType: 'Error', + errorMessage: 'Network timeout', + retryable: false, + }), + ); + }); + + it('does not rethrow errors from refresh', async () => { + const logger = createMockLogger(); + const refreshFn = jest.fn().mockRejectedValue(new Error('crash')); + const safeRefresh = createSafeRefresh(refreshFn, 'ociSkill', logger); + + await expect(safeRefresh()).resolves.toBeUndefined(); + }); + + it('logs error context fields including connectorId', async () => { + const logger = createMockLogger(); + const error = new Error('Connection refused'); + (error as NodeJS.ErrnoException).code = 'ECONNREFUSED'; + const refreshFn = jest.fn().mockRejectedValue(error); + + const safeRefresh = createSafeRefresh(refreshFn, 'rhoai', logger); + await safeRefresh(); + + expect(logger.error).toHaveBeenCalledWith( + 'Connector refresh failed', + expect.objectContaining({ + connectorId: 'rhoai', + errorType: 'Error', + errorMessage: 'Connection refused', + retryable: true, + }), + ); + }); +}); diff --git a/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.ts b/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.ts new file mode 100644 index 00000000000..a4082b8196d --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/fault-isolation.ts @@ -0,0 +1,228 @@ +/* + * 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 type { LoggerService } from '@backstage/backend-plugin-api'; +import type { ConnectorEntityProvider, ConnectorErrorContext } from './types'; + +/** + * Retryable (transient) error codes and HTTP status codes. + * + * @internal + */ +const RETRYABLE_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'EPIPE', + 'EAI_AGAIN', +]); + +const RETRYABLE_HTTP_STATUSES = new Set([429, 500, 502, 503, 504]); + +/** + * Non-retryable error type names. + * + * @internal + */ +const NON_RETRYABLE_TYPES = new Set(['TypeError', 'SyntaxError', 'ZodError']); + +/** + * Non-retryable TLS error codes. + * + * @internal + */ +const NON_RETRYABLE_TLS_CODES = new Set([ + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'CERT_HAS_EXPIRED', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'ERR_TLS_CERT_ALTNAME_INVALID', +]); + +const NON_RETRYABLE_HTTP_STATUSES = new Set([400, 401, 403, 404]); + +/** + * Classify whether a connector error is retryable (transient) + * or non-retryable (fatal). + * + * @param error - The error to classify. + * @returns `true` if the error is transient and a retry is recommended. + * + * @public + */ +export function classifyConnectorError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + const err = error as Error & { + code?: string; + status?: number; + statusCode?: number; + cause?: Error & { code?: string }; + response?: { status?: number }; + }; + + const code = err.code; + const causeCode = err.cause?.code; + + // Non-retryable TLS errors (check code and cause.code) + if (code && NON_RETRYABLE_TLS_CODES.has(code)) { + return false; + } + if (causeCode && NON_RETRYABLE_TLS_CODES.has(causeCode)) { + return false; + } + + // HTTP status: support err.status, err.statusCode, and + // axios-shaped err.response.status + const httpStatus = err.status ?? err.statusCode ?? err.response?.status; + + // Non-retryable HTTP statuses + if (httpStatus && NON_RETRYABLE_HTTP_STATUSES.has(httpStatus)) { + return false; + } + + // Retryable by error code (check both err.code and err.cause?.code + // so native-fetch TypeErrors with a network cause are retryable) + if (code && RETRYABLE_CODES.has(code)) { + return true; + } + if (causeCode && RETRYABLE_CODES.has(causeCode)) { + return true; + } + + // Retryable by HTTP status + if (httpStatus && RETRYABLE_HTTP_STATUSES.has(httpStatus)) { + return true; + } + + // Non-retryable by error type — checked AFTER code/cause so that + // native-fetch TypeErrors with a retryable network cause (e.g. + // TypeError("fetch failed") + cause.code=ECONNREFUSED) are not + // short-circuited. Only bare TypeErrors (malformed URL, etc.) + // reach here. + if (NON_RETRYABLE_TYPES.has(err.constructor.name)) { + return false; + } + + // Default: non-retryable + return false; +} + +/** + * Optional context that connectors pass to fault-isolation wrappers. + * + * @public + */ +export interface FaultIsolationContext { + /** External API endpoint that failed (if known). */ + endpoint?: string; + /** + * ISO-8601 timestamp of the next scheduled retry. Connector-owned — + * the wrapper utilities log this value but never compute it; connectors + * that schedule retries should supply it. + */ + nextRetryAt?: string; +} + +/** + * Build a ConnectorErrorContext from an error. + * + * @internal + */ +function buildErrorContext( + error: unknown, + connectorId: string, + ctx?: FaultIsolationContext, +): ConnectorErrorContext { + const err = error instanceof Error ? error : new Error(String(error)); + const retryable = classifyConnectorError(error); + + return { + connectorId, + endpoint: ctx?.endpoint, + errorType: err.constructor.name, + errorMessage: err.message, + retryable, + ...(retryable && ctx?.nextRetryAt + ? { nextRetryAt: ctx.nextRetryAt } + : undefined), + }; +} + +/** + * Wrap an EntityProvider so that its `connect()` method catches + * unhandled errors and logs them instead of crashing the process. + * + * @param provider - The original entity provider. + * @param logger - Backstage LoggerService for structured logging. + * @param ctx - Optional context with endpoint URL and retry schedule. + * @returns A wrapped EntityProvider that never throws from `connect()`. + * + * @public + */ +export function createProviderWrapper( + provider: ConnectorEntityProvider, + logger: LoggerService, + ctx?: FaultIsolationContext, +): ConnectorEntityProvider { + return { + getProviderName: () => provider.getProviderName(), + async connect(connection: unknown): Promise { + try { + await provider.connect(connection); + } catch (error) { + const errorCtx = buildErrorContext( + error, + provider.getProviderName(), + ctx, + ); + logger.error('Connector connect() failed', errorCtx); + // Don't rethrow — allow other providers to continue + } + }, + }; +} + +/** + * Wrap a scheduled refresh callback in try/catch to prevent + * unhandled rejections from crashing the Node.js process. + * + * @param refreshFn - The refresh callback to wrap. + * @param connectorId - Provider identifier for error context. + * @param logger - Backstage LoggerService for structured logging. + * @param ctx - Optional context with endpoint URL and retry schedule. + * @returns A wrapped callback that never throws. + * + * @public + */ +export function createSafeRefresh( + refreshFn: () => Promise, + connectorId: string, + logger: LoggerService, + ctx?: FaultIsolationContext, +): () => Promise { + return async () => { + try { + await refreshFn(); + } catch (error) { + const errorCtx = buildErrorContext(error, connectorId, ctx); + logger.error('Connector refresh failed', errorCtx); + // Don't rethrow — allow catalog backend to continue + } + }; +} diff --git a/workspaces/boost/plugins/boost-connector-utils/src/index.ts b/workspaces/boost/plugins/boost-connector-utils/src/index.ts new file mode 100644 index 00000000000..da31b6b34f2 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/index.ts @@ -0,0 +1,39 @@ +/* + * 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. + */ + +/** + * Shared utilities for Boost AI catalog connector entity providers. + * + * Provides CA bundle resolution, fault isolation wrappers, + * enable/disable patterns, and configurable endpoint/credential + * validation for air-gapped deployments. + * + * @packageDocumentation + */ + +export { loadCaBundle, createHttpsAgent } from './ca-bundle'; +export { + createProviderWrapper, + createSafeRefresh, + classifyConnectorError, + type FaultIsolationContext, +} from './fault-isolation'; +export { isConnectorEnabled, validateConnectorStartupConfig } from './config'; +export type { + ConnectorEntityProvider, + ConnectorErrorContext, + ValidateConnectorStartupConfigOptions, +} from './types'; diff --git a/workspaces/boost/plugins/boost-connector-utils/src/types.ts b/workspaces/boost/plugins/boost-connector-utils/src/types.ts new file mode 100644 index 00000000000..6d9f2946ed7 --- /dev/null +++ b/workspaces/boost/plugins/boost-connector-utils/src/types.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +/** + * Minimal entity provider interface matching Backstage's EntityProvider + * contract. Defined locally to avoid a dependency on + * `@backstage/plugin-catalog-node`. + * + * @public + */ +export interface ConnectorEntityProvider { + /** Returns the provider's unique name. */ + getProviderName(): string; + /** Connects the provider to the catalog. */ + connect(connection: unknown): Promise; +} + +/** + * Structured error context logged when a connector fails. + * + * @public + */ +export interface ConnectorErrorContext { + /** Provider identifier (e.g., 'mcpRegistry'). */ + connectorId: string; + /** External API endpoint that failed (if known). */ + endpoint?: string; + /** Error constructor name (e.g., 'FetchError', 'TimeoutError'). */ + errorType: string; + /** Human-readable error message. */ + errorMessage: string; + /** Whether this error is transient (retry recommended). */ + retryable: boolean; + /** ISO timestamp of next scheduled retry (present only when retryable). */ + nextRetryAt?: string; + /** Index signature for compatibility with Backstage LoggerService metadata (JsonObject). */ + [key: string]: string | boolean | undefined; +} + +/** + * Options for startup config validation. + * + * @public + */ +export interface ValidateConnectorStartupConfigOptions { + /** + * Config keys (relative to the connector Config subtree) that hold + * credential values and must use Backstage `$env` references. + * Example: `['auth.token', 'auth.clientSecret']` + */ + credentialFields: string[]; + + /** + * Config key (relative to the connector Config subtree) that holds + * the endpoint URL. If provided, the value is validated as a valid + * HTTPS URL. + * Example: `'endpoint'` + */ + endpointField?: string; +} diff --git a/workspaces/boost/plugins/boost-node/package.json b/workspaces/boost/plugins/boost-node/package.json index ebe2f41f845..7a9037b0bb4 100644 --- a/workspaces/boost/plugins/boost-node/package.json +++ b/workspaces/boost/plugins/boost-node/package.json @@ -19,6 +19,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/plugins/boost-responses-api-toolkit/package.json b/workspaces/boost/plugins/boost-responses-api-toolkit/package.json index 58584789d6a..2a61bd0262e 100644 --- a/workspaces/boost/plugins/boost-responses-api-toolkit/package.json +++ b/workspaces/boost/plugins/boost-responses-api-toolkit/package.json @@ -19,6 +19,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/plugins/boost-toolscope/package.json b/workspaces/boost/plugins/boost-toolscope/package.json index 143923bd2f4..272be3d04e8 100644 --- a/workspaces/boost/plugins/boost-toolscope/package.json +++ b/workspaces/boost/plugins/boost-toolscope/package.json @@ -19,6 +19,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/plugins/boost/package.json b/workspaces/boost/plugins/boost/package.json index 7104cb25060..4e29fa0ad4f 100644 --- a/workspaces/boost/plugins/boost/package.json +++ b/workspaces/boost/plugins/boost/package.json @@ -31,6 +31,7 @@ "@red-hat-developer-hub/backstage-plugin-boost", "@red-hat-developer-hub/backstage-plugin-boost-backend", "@red-hat-developer-hub/backstage-plugin-boost-common", + "@red-hat-developer-hub/backstage-plugin-boost-connector-utils", "@red-hat-developer-hub/backstage-plugin-boost-node", "@red-hat-developer-hub/backstage-plugin-boost-responses-api-toolkit", "@red-hat-developer-hub/backstage-plugin-boost-toolscope" diff --git a/workspaces/boost/yarn.lock b/workspaces/boost/yarn.lock index 62f29b3e8bd..8b48cd4ddd1 100644 --- a/workspaces/boost/yarn.lock +++ b/workspaces/boost/yarn.lock @@ -8847,6 +8847,16 @@ __metadata: languageName: unknown linkType: soft +"@red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:plugins/boost-connector-utils": + version: 0.0.0-use.local + resolution: "@red-hat-developer-hub/backstage-plugin-boost-connector-utils@workspace:plugins/boost-connector-utils" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.9.2" + "@backstage/cli": "npm:^0.36.3" + "@backstage/config": "npm:^1.3.8" + languageName: unknown + linkType: soft + "@red-hat-developer-hub/backstage-plugin-boost-node@workspace:^, @red-hat-developer-hub/backstage-plugin-boost-node@workspace:plugins/boost-node": version: 0.0.0-use.local resolution: "@red-hat-developer-hub/backstage-plugin-boost-node@workspace:plugins/boost-node"