From 133519ba25502b30db5b05f326bc43daf18963c1 Mon Sep 17 00:00:00 2001 From: John Redwood Date: Mon, 4 Aug 2025 22:23:27 +1000 Subject: [PATCH 1/5] feat: new cache manager infinispan Signed-off-by: John Redwood --- .changeset/tough-clubs-pick.md | 6 + .../config/vocabularies/Backstage/accept.txt | 1 + docs/overview/architecture-overview.md | 38 ++- packages/backend-defaults/config.d.ts | 179 ++++++++++++++ packages/backend-defaults/package.json | 1 + .../src/entrypoints/cache/CacheManager.ts | 209 ++++++++++++---- .../infinispan/InfinispanKeyvStore.test.ts | 177 ++++++++++++++ .../infinispan/InfinispanKeyvStore.ts | 206 ++++++++++++++++ .../infinispan/InfinispanOptionsMapper.ts | 225 ++++++++++++++++++ .../src/entrypoints/cache/types.ts | 97 +++++++- packages/backend-test-utils/package.json | 1 + yarn.lock | 160 +++++++++++-- 12 files changed, 1239 insertions(+), 61 deletions(-) create mode 100644 .changeset/tough-clubs-pick.md create mode 100644 packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.test.ts create mode 100644 packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.ts create mode 100644 packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts diff --git a/.changeset/tough-clubs-pick.md b/.changeset/tough-clubs-pick.md new file mode 100644 index 0000000000..9b0f5c31b9 --- /dev/null +++ b/.changeset/tough-clubs-pick.md @@ -0,0 +1,6 @@ +--- +'@backstage/backend-test-utils': minor +'@backstage/backend-defaults': minor +--- + +feat: new cache manager `Infinispan Data Grid` diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 4682640f90..ff6ea3aacb 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -199,6 +199,7 @@ impactful incentivised Indal indexable +infinispan ingestors inlined inlinehilite diff --git a/docs/overview/architecture-overview.md b/docs/overview/architecture-overview.md index 035be95769..63e2c16e3c 100644 --- a/docs/overview/architecture-overview.md +++ b/docs/overview/architecture-overview.md @@ -284,8 +284,8 @@ stores as a means of improving performance or reliability. Similar to how databases are supported, plugins receive logically separated cache connections, which are powered by [Keyv](https://github.com/lukechilds/keyv) under the hood. -At this time of writing, Backstage can be configured to use one of three cache -stores: memory, which is mainly used for local testing, memcache or Redis, +At this time of writing, Backstage can be configured to use one of five cache +stores: memory, which is mainly used for local testing, memcache, redis, valkey or infinispan, which are cache stores better suited for production deployment. The right cache store for your Backstage instance will depend on your own run-time constraints and those required of the plugins you're running. @@ -316,6 +316,40 @@ backend: connection: redis://user:pass@cache.example.com:6379 ``` +### Use Infinispan for cache + +#### Minimal configuration + +- defaults, no `authentication`, expects cache named `cache` and host `127.0.0.1:11222`) + +```yaml +backend: + cache: + store: infinispan +``` + +#### Extended configuration + +- Unlike Redis, Infinispan will **not** create the cache for you. It's expected you've configured the cache in your infinispan server prior to configuration here. +- A full list of configuration items are available: https://docs.jboss.org/infinispan/hotrod-clients/javascript/1.0/apidocs/module-infinispan.html including support for backup clusters. + +```yaml +backend: + cache: + store: infinispan + infinispan: + servers: + - host: 127.0.0.1 + port: 11222 + cacheName: backstage-cache + mediaType: application/json + authentication: + enabled: true + userName: yourusername + password: yourpassword + saslMechanism: PLAIN +``` + Contributions supporting other cache stores are welcome! ## Containerization diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 167a8935f9..019b76f407 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -750,6 +750,185 @@ export interface Config { connection: string; /** An optional default TTL (in milliseconds). */ defaultTtl?: number | HumanDuration | string; + } + | { + /** + * Infinispan cache store configuration. + * @see https://docs.jboss.org/infinispan/hotrod-clients/javascript/1.0/apidocs/module-infinispan.html + */ + store: 'infinispan'; + + /** + * An optional default TTL (in milliseconds). + */ + defaultTtl?: number | HumanDuration | string; + + /** + * Configuration for the Infinispan cache store. + */ + infinispan?: { + /** + * Version of client/server protocol. + * @default '2.9' is the latest version. + */ + version?: '2.9' | '2.5' | '2.2'; + + /** + * Infinispan Cache Name if not provided default is `cache` recommended to set this. + */ + cacheName?: string; + + /** + * Optional number of retries for operation. + * Defaults to 3. + */ + maxRetries?: number; + + /** + * Optional flag to controls whether the client deals with topology updates or not. + * @default true + */ + topologyUpdates?: boolean; + + /** + * Media type of the cache contents. + * @default 'text/plain' + */ + mediaType?: 'text/plain' | 'application/json'; + + /** + * Infinispan server host and port configuration. + * If this is an array, the client will connect to all servers in the list based on TOPOLOGY_AWARE routing. + * If this is a single object, it will be used as the default server. + */ + servers?: + | Array<{ + /** + * Infinispan server host. + */ + host: string; + /** + * Infinispan server port (Hot Rod protocol). Defaults to `11222`. + */ + port?: number; + }> + | { + /** + * Infinispan server host. Defaults to `127.0.0.1`. + */ + host?: string; + /** + * Infinispan server port (Hot Rod protocol). Defaults to `11222`. + */ + port?: number; + }; + authentication?: { + /** + * Enable authentication. Defaults to `false`. + */ + enabled?: boolean; + /** + * Select the SASL mechanism to use. Can be one of PLAIN, DIGEST-MD5, SCRAM-SHA-1, SCRAM-SHA-256, SCRAM-SHA-384, SCRAM-SHA-512, EXTERNAL, OAUTHBEARER + */ + saslMechanism?: string; + /** + * userName for authentication. + */ + userName?: string; + /** + * Password for authentication. + * @visibility secret + */ + password?: string; + /** + * The OAuth token. Required by the OAUTHBEARER mechanism. + * @visibility secret + */ + token?: string; + /** + * The SASL authorization ID. + */ + authzid?: string; + }; + + /** + * TLS/SSL configuration. + */ + ssl?: { + /** + * Enable ssl connection. Defaults to `false`. + * @default false + */ + enabled?: boolean; + + /** + * Optional field with secure protocol in use. + * @default TLSv1_2_method + */ + secureProtocol?: string; + + /** + * Optional paths of trusted SSL certificates. + */ + trustCerts?: Array; + + clientAuth?: { + /** + * Optional path to client authentication key + */ + key?: string; + /** + * Optional password for client key + */ + passphrase?: string; + /** + * Optional client certificate + */ + cert?: string; + }; + + /** + * Optional SNI host name. + */ + sniHostName?: string; + + /** + * Optional crypto store configuration. + */ + cryptoStore?: { + /** Optional crypto store path. */ + path?: string; + /** Optional password for crypto store. */ + passphrase?: string; + }; + }; + + /** + * Optional additional clusters for cross-site failovers. + * Array. + */ + clusters?: Array<{ + /** + * Optional Cluster name + */ + name?: string; + + /** + * Cluster servers details. + * Array. + */ + servers: Array<{ + /** + * Infinispan cluster server host. + */ + host: string; + /** + * Infinispan server port (Hot Rod protocol). Defaults to `11222`. + */ + port?: number; + }>; + }>; + }; }; cors?: { diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 95ea490bb2..04268dff5f 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -168,6 +168,7 @@ "fs-extra": "^11.2.0", "git-url-parse": "^15.0.0", "helmet": "^6.0.0", + "infinispan": "^0.12.0", "is-glob": "^4.0.3", "jose": "^5.0.0", "keyv": "^5.2.1", diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 9a691bdb21..9315593a39 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -27,9 +27,16 @@ import { ttlToMilliseconds, CacheStoreOptions, RedisCacheStoreOptions, + InfinispanClientBehaviorOptions, + InfinispanServerConfig, } from './types'; +import { InfinispanOptionsMapper } from './providers/infinispan/InfinispanOptionsMapper'; import { durationToMilliseconds } from '@backstage/types'; -import { readDurationFromConfig } from '@backstage/config'; +import { ConfigReader, readDurationFromConfig } from '@backstage/config'; +import { + InfinispanClientCacheInterface, + InfinispanKeyvStore, +} from './providers/infinispan/InfinispanKeyvStore'; type StoreFactory = (pluginId: string, defaultTtl: number | undefined) => Keyv; @@ -50,6 +57,7 @@ export class CacheManager { valkey: this.createValkeyStoreFactory(), memcache: this.createMemcacheStoreFactory(), memory: this.createMemoryStoreFactory(), + infinispan: this.createInfinispanStoreFactory(), }; private readonly logger?: LoggerService; @@ -64,6 +72,8 @@ export class CacheManager { * config section, specifically the `.cache` key. * * @param config - The loaded application configuration. + * @param options - Optional configuration for the CacheManager. + * @returns A new CacheManager instance. */ static fromConfig( config: RootConfigService, @@ -112,7 +122,7 @@ export class CacheManager { /** * Parse store-specific options from configuration. * - * @param store - The cache store type ('redis', 'valkey', 'memcache', or 'memory') + * @param store - The cache store type ('redis', 'valkey', 'memcache', 'infinispan', or 'memory') * @param config - The configuration service * @param logger - Optional logger for warnings * @returns The parsed store options @@ -124,11 +134,27 @@ export class CacheManager { ): CacheStoreOptions | undefined { const storeConfigPath = `backend.cache.${store}`; - if ( - (store === 'redis' || store === 'valkey') && - config.has(storeConfigPath) - ) { - return CacheManager.parseRedisOptions(storeConfigPath, config, logger); + if (!config.has(storeConfigPath)) { + logger?.warn( + `No configuration found for cache store '${store}' at '${storeConfigPath}'.`, + ); + } + + if (store === 'redis' || store === 'valkey') { + return CacheManager.parseRedisOptions( + store, + storeConfigPath, + config, + logger, + ); + } + + if (store === 'infinispan') { + return InfinispanOptionsMapper.parseInfinispanOptions( + storeConfigPath, + config, + logger, + ); } return undefined; @@ -138,12 +164,17 @@ export class CacheManager { * Parse Redis-specific options from configuration. */ private static parseRedisOptions( + store: string, storeConfigPath: string, config: RootConfigService, logger?: LoggerService, ): RedisCacheStoreOptions { - const redisOptions: RedisCacheStoreOptions = {}; - const redisConfig = config.getConfig(storeConfigPath); + const redisOptions: RedisCacheStoreOptions = { + type: store as 'redis' | 'valkey', + }; + + const redisConfig = + config.getOptionalConfig(storeConfigPath) ?? new ConfigReader({}); redisOptions.client = { namespace: redisConfig.getOptionalString('client.namespace'), @@ -231,23 +262,25 @@ export class CacheManager { return (pluginId, defaultTtl) => { if (!stores[pluginId]) { - const redisOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Redis cluster - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvRedis(cluster, redisOptions); - } else { - // Create a regular Redis connection - stores[pluginId] = new KeyvRedis(this.connection, redisOptions); - } + if (this.storeOptions?.type === 'redis') { + const redisOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Redis cluster + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvRedis(cluster, redisOptions); + } else { + // Create a regular Redis connection + stores[pluginId] = new KeyvRedis(this.connection, redisOptions); + } - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create redis cache client', err); - this.errorHandler?.(err); - }); + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create redis cache client', err); + this.errorHandler?.(err); + }); + } } return new Keyv({ namespace: pluginId, @@ -266,23 +299,25 @@ export class CacheManager { return (pluginId, defaultTtl) => { if (!stores[pluginId]) { - const valkeyOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Valkey cluster (Redis cluster under the hood) - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); - } else { - // Create a regular Valkey connection - stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); - } + if (this.storeOptions?.type === 'valkey') { + const valkeyOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Valkey cluster (Redis cluster under the hood) + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); + } else { + // Create a regular Valkey connection + stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); + } - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create valkey cache client', err); - this.errorHandler?.(err); - }); + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create valkey cache client', err); + this.errorHandler?.(err); + }); + } } return new Keyv({ namespace: pluginId, @@ -326,4 +361,96 @@ export class CacheManager { store, }); } + + private createInfinispanStoreFactory(): StoreFactory { + const stores: Record = {}; + + return (pluginId, defaultTtl) => { + if (!stores[pluginId]) { + if (this.storeOptions?.type === 'infinispan') { + // Use sync version for testing environments + const isTest = + process.env.NODE_ENV === 'test' || typeof jest !== 'undefined'; + + // Create the client promise ONCE and reuse it + const clientPromise: Promise = isTest + ? this.createInfinispanClientSync() + : this.createInfinispanClientAsync(); + + this.logger?.info( + `Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`, + ); + const storeInstance = new InfinispanKeyvStore({ + clientPromise, + logger: this.logger!, + }); + + stores[pluginId] = storeInstance; + + // Always provide an error handler to avoid stopping the process + storeInstance.on('error', (err: Error) => { + this.logger?.error('Failed to create infinispan cache client', err); + this.errorHandler?.(err); + }); + } + } + + return new Keyv({ + namespace: pluginId, + ttl: defaultTtl, + store: stores[pluginId], + emitErrors: false, + }); + }; + } + + /** + * Creates an Infinispan client using dynamic import (production use). + * @returns Promise that resolves to an Infinispan client + */ + private async createInfinispanClientAsync(): Promise { + return this.createInfinispanClient(false); + } + + /** + * Creates an Infinispan client using synchronous import (testing purposes). + * @returns Promise that resolves to an Infinispan client + */ + private createInfinispanClientSync(): Promise { + return this.createInfinispanClient(true); + } + + /** + * Creates an Infinispan client based on the provided configuration. + * @param useSync - Whether to use synchronous import (for testing) or dynamic import + * @returns Promise that resolves to an Infinispan client + */ + private async createInfinispanClient( + useSync: boolean = false, + ): Promise { + try { + this.logger?.info('Creating Infinispan client'); + + if (this.storeOptions?.type === 'infinispan') { + // Import infinispan based on the useSync parameter + const infinispan = useSync + ? require('infinispan') + : await import('infinispan'); + + const client = await infinispan.client( + this.storeOptions.servers as InfinispanServerConfig[], + this.storeOptions.options as InfinispanClientBehaviorOptions, + ); + + this.logger?.info('Infinispan client created successfully'); + return client; + } + throw new Error('Infinispan store options are not defined'); + } catch (error: any) { + this.logger?.error('Failed to create Infinispan client', { + error: error.message, + }); + throw error; + } + } } diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.test.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.test.ts new file mode 100644 index 0000000000..4f6b2b1ba0 --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.test.ts @@ -0,0 +1,177 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { InfinispanKeyvStore } from './InfinispanKeyvStore'; +import { mockServices } from '@backstage/backend-test-utils'; + +describe('InfinispanKeyvStore', () => { + const logger = mockServices.logger.mock(); + let store: InfinispanKeyvStore; + let mockClient: any; + + beforeEach(() => { + mockClient = { + get: jest.fn(), + put: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), + disconnect: jest.fn(), + on: jest.fn(), + }; + + store = new InfinispanKeyvStore({ + clientPromise: Promise.resolve(mockClient), + logger, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('get', () => { + it('returns undefined when key is not found', async () => { + mockClient.get.mockResolvedValueOnce(null); + const result = await store.get('test-key'); + expect(result).toBeUndefined(); + expect(mockClient.get).toHaveBeenCalledWith('test-key'); + }); + + it('returns value when key is found', async () => { + const value = 'test-value'; + mockClient.get.mockResolvedValueOnce(value); + const result = await store.get('test-key'); + expect(result).toBe(value); + expect(mockClient.get).toHaveBeenCalledWith('test-key'); + }); + + it('handles client errors', async () => { + const error = new Error('Connection error'); + mockClient.get.mockRejectedValueOnce(error); + await expect(store.get('test-key')).rejects.toThrow('Connection error'); + }); + }); + + describe('set', () => { + it('sets value with default options', async () => { + await store.set('test-key', 'test-value'); + expect(mockClient.put).toHaveBeenCalledWith('test-key', 'test-value', {}); + }); + + it('sets value with TTL', async () => { + await store.set('test-key', 'test-value', 1000); + expect(mockClient.put).toHaveBeenCalledWith('test-key', 'test-value', { + lifespan: '1000ms', + }); + }); + + it('handles client errors', async () => { + const error = new Error('Connection error'); + mockClient.put.mockRejectedValueOnce(error); + await expect(store.set('test-key', 'test-value')).rejects.toThrow( + 'Connection error', + ); + }); + }); + + describe('delete', () => { + it('deletes key', async () => { + mockClient.remove.mockResolvedValueOnce(true); + const result = await store.delete('test-key'); + expect(result).toBe(true); + expect(mockClient.remove).toHaveBeenCalledWith('test-key'); + }); + + it('handles client errors', async () => { + const error = new Error('Connection error'); + mockClient.remove.mockRejectedValueOnce(error); + await expect(store.delete('test-key')).rejects.toThrow( + 'Connection error', + ); + }); + }); + + describe('clear', () => { + it('clears all entries', async () => { + await store.clear(); + expect(mockClient.clear).toHaveBeenCalled(); + }); + + it('handles client errors', async () => { + const error = new Error('Connection error'); + mockClient.clear.mockRejectedValueOnce(error); + await expect(store.clear()).rejects.toThrow('Connection error'); + }); + }); + + describe('disconnect', () => { + it('is a no-op as client is managed by CacheManager', async () => { + const disconnectMockClient = { + disconnect: jest.fn().mockResolvedValue(undefined), + get: jest.fn().mockResolvedValue(null), + put: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(undefined), + }; + const disconnectStore = new InfinispanKeyvStore({ + clientPromise: Promise.resolve(disconnectMockClient), + logger: mockServices.logger.mock(), + }); + await disconnectStore.disconnect(); + expect(disconnectMockClient.disconnect).not.toHaveBeenCalled(); + }); + + it('handles client errors', async () => { + const error = new Error('Connection error'); + const errorMockClient = { + disconnect: jest.fn().mockRejectedValue(error), + get: jest.fn().mockResolvedValue(null), + put: jest.fn().mockResolvedValue(undefined), + remove: jest.fn().mockResolvedValue(undefined), + clear: jest.fn().mockResolvedValue(undefined), + }; + const errorStore = new InfinispanKeyvStore({ + clientPromise: Promise.resolve(errorMockClient), + logger: mockServices.logger.mock(), + }); + await expect(errorStore.disconnect()).resolves.not.toThrow(); + }); + }); + + describe('error handling', () => { + it('emits error events from client', async () => { + const error = new Error('Client error'); + const errorHandler = jest.fn(); + store.on('error', errorHandler); + + // Simulate client error + const clientPromise = Promise.resolve(mockClient); + store = new InfinispanKeyvStore({ + clientPromise, + logger, + }); + + // Wait for client to be resolved + await clientPromise; + + // Trigger error event + const errorCallback = mockClient.on.mock.calls[0][1]; + errorCallback(error); + + expect(errorHandler).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.ts new file mode 100644 index 0000000000..b6efca718f --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanKeyvStore.ts @@ -0,0 +1,206 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { EventEmitter } from 'events'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { InfinispanPutOptions } from '../../types'; + +/** + * Interface defining the required methods for an Infinispan client. + * @public + */ +export interface InfinispanClientCacheInterface { + get(key: string): Promise; + put(key: string, value: string, options?: InfinispanPutOptions): Promise; + remove(key: string): Promise; + clear(): Promise; + disconnect(): Promise; + on?(event: 'error' | string, listener: (...args: any[]) => void): this; + connect?(): Promise; + query?(query: string): Promise; + containsKey?(key: string): Promise; +} + +/** + * Options for creating an InfinispanKeyvStore instance. + */ +export interface InfinispanKeyvStoreOptions { + clientPromise: Promise; + logger: LoggerService; + defaultTtl?: number; // TTL in milliseconds +} + +/** + * A Keyv store implementation that uses Infinispan as the backend. + * This store implements the Keyv store interface and provides caching functionality + * using Infinispan's distributed cache capabilities. + */ +export class InfinispanKeyvStore extends EventEmitter { + private readonly clientPromise: Promise; + private readonly logger: LoggerService; + private readonly defaultTtl?: number; + private resolvedClient: InfinispanClientCacheInterface | null = null; + + public readonly namespace?: string; // Keyv expects this + + constructor(options: InfinispanKeyvStoreOptions) { + super(); + this.clientPromise = options.clientPromise; + this.logger = options.logger.child({ class: InfinispanKeyvStore.name }); + this.defaultTtl = options.defaultTtl; + + // Eagerly try to resolve the client to attach error listeners early + // and to have it ready for disconnect if resolved. + this.clientPromise + .then(client => { + this.resolvedClient = client; + if (typeof client.on === 'function') { + client.on('error', (error: Error) => { + this.logger.error('Native Infinispan client reported an error.', { + error: error.message, + }); + this.emit('error', error); + }); + } else { + this.logger.warn( + 'Native Infinispan client does not appear to support .on("error") event listening.', + ); + } + }) + .catch(err => { + this.logger.error( + 'Failed to resolve Infinispan client promise in constructor.', + { error: err.message }, + ); + // Errors from operations will also be emitted when clientPromise is awaited and rejects. + this.emit('error', err); + }); + } + + private async getClient(): Promise { + if (this.resolvedClient) { + return this.resolvedClient; + } + // If not yet resolved (e.g. called very quickly or promise rejected and retrying implicitly) + // Await the promise. This will throw if the promise is rejected. + this.resolvedClient = await this.clientPromise; + return this.resolvedClient; + } + + async get(key: string): Promise { + this.logger.debug(`Getting key: ${key}`); + try { + const client = await this.getClient(); + const value = await client.get(key); + if (value === null || value === undefined) { + this.logger.debug(`Key not found or value is null/undefined: ${key}`); + return undefined; + } + this.logger.debug(`Successfully retrieved key: ${key}`); + return value; + } catch (error: any) { + this.logger.error(`Error getting key '${key}' from Infinispan.`, { + error: error.message, + }); + this.emit( + 'error', + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } + } + + async set(key: string, value: string, ttl?: number): Promise { + this.logger.debug(`Setting key: ${key}`, { ttl }); + this.logger.debug(`Setting key: ${key}`, { ttlInput: ttl }); + const currentTtl = ttl ?? this.defaultTtl; + this.logger.debug(`Calculated currentTtl for key ${key}: ${currentTtl}ms`); // Log do TTL calculado + const storeOptions: InfinispanPutOptions = {}; + + if (typeof currentTtl === 'number' && currentTtl > 0) { + storeOptions.lifespan = `${currentTtl}ms`; // Ensure time unit is passed as string + // Ensure this matches client expectations. If client expects string '10s', convert here. + // For now, assuming number in ms is fine or string like '10000ms'. + // The PutOptions defines lifespan as string | number | null. + } else if (typeof currentTtl === 'string') { + storeOptions.lifespan = currentTtl; + } + + try { + const client = await this.getClient(); + await client.put(key, value, storeOptions); + this.logger.debug(`Successfully set key: ${key}`); + } catch (error: any) { + this.logger.error(`Error setting key '${key}' in Infinispan.`, { + error: error.message, + }); + this.emit( + 'error', + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } + } + + async delete(key: string): Promise { + this.logger.debug(`Deleting key: ${key}`); + try { + const client = await this.getClient(); + const deleted = await client.remove(key); + this.logger.debug(`Key deletion status for '${key}': ${deleted}`); + return deleted; + } catch (error: any) { + this.logger.error(`Error deleting key '${key}' from Infinispan.`, { + error: error.message, + }); + this.emit( + 'error', + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } + } + + async clear(): Promise { + this.logger.info('Clearing all entries from Infinispan cache.'); + try { + const client = await this.getClient(); + await client.clear(); + this.logger.info('Infinispan cache cleared successfully.'); + } catch (error: any) { + this.logger.error('Error clearing Infinispan cache.', { + error: error.message, + }); + this.emit( + 'error', + error instanceof Error ? error : new Error(String(error)), + ); + throw error; + } + } + + // This disconnect is for the Keyv store instance, but the actual client is shared. + // The CacheManager should handle the shared client's disconnection. + // However, if Keyv calls this, we shouldn't error. + async disconnect(): Promise { + this.logger.info( + 'InfinispanKeyvStore disconnect called. Shared client managed by CacheManager.', + ); + // No-op for this store instance as the actual client is managed externally by CacheManager. + // The CacheManager's stop() method will disconnect the shared native client. + return Promise.resolve(); + } +} diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts new file mode 100644 index 0000000000..58234bb11d --- /dev/null +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts @@ -0,0 +1,225 @@ +/* + * Copyright 2025 The Backstage Authors + * + * 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 { + LoggerService, + RootConfigService, +} from '@backstage/backend-plugin-api'; +import { + InfinispanAuthOptions, + InfinispanCacheStoreOptions, + InfinispanClientAuthOptions, + InfinispanClientBehaviorOptions, + InfinispanClusterConfig, + InfinispanServerConfig, + InfinispanSslOptions, +} from '../../types'; + +export class InfinispanOptionsMapper { + /** + * Parses Infinispan options from the provided configuration path. + * + * @param storeConfigPath - The configuration path for the Infinispan store. + * @param config - The root configuration service to retrieve the Infinispan configuration. + * @param logger - An optional logger service for logging errors and warnings. + * @returns Parsed Infinispan cache store options. + */ + public static parseInfinispanOptions( + storeConfigPath: string, + config: RootConfigService, + logger?: LoggerService, + ): InfinispanCacheStoreOptions { + const infinispanConfig = config.getConfig(storeConfigPath); + const parsedOptions: Partial = { + type: 'infinispan', + }; + + // Parse Servers & Clusters Configurations + if (infinispanConfig.has('servers')) { + const serversConfig = infinispanConfig.get('servers'); + if (Array.isArray(serversConfig)) { + parsedOptions.servers = infinispanConfig.getConfigArray('servers').map( + serverConf => + ({ + host: serverConf.getString('host'), + port: serverConf.getNumber('port'), + } as InfinispanServerConfig), + ); + } else if (typeof serversConfig === 'object' && serversConfig !== null) { + const serverConf = infinispanConfig.getConfig('servers'); + parsedOptions.servers = { + host: serverConf.getString('host'), + port: serverConf.getNumber('port'), + } as InfinispanServerConfig; + } else { + logger?.error( + `Infinispan 'servers' configuration at ${storeConfigPath} must be an object or an array.`, + ); + throw new Error( + `Infinispan 'servers' configuration at ${storeConfigPath} is invalid.`, + ); + } + } else { + logger?.error( + `Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition.`, + ); + throw new Error( + `Infinispan configuration at ${storeConfigPath} must define 'servers'.`, + ); + } + + // The parsed options block to send to the Infinispan client + // This will be used to configure the Infinispan client behavior. + const behaviorOptions: Partial = {}; + + if (infinispanConfig.has('clusters')) { + const clustersConfig = infinispanConfig.getConfigArray('clusters'); + const clusterOptions: InfinispanClusterConfig[] = []; + clustersConfig?.forEach(clusterConf => { + const name = clusterConf.getOptionalString('name'); + const cluster: InfinispanClusterConfig = { + ...(name && { name }), + servers: clusterConf.getConfigArray('servers').map(serverConf => ({ + host: serverConf.getString('host'), + port: serverConf.getNumber('port'), + })), + }; + clusterOptions.push(cluster); + behaviorOptions.clusters = clusterOptions; + }); + } + + // Parse Default Options Start... + if (infinispanConfig.has('version')) { + const clientVersion = infinispanConfig.getString('version'); + if ( + clientVersion === '2.9' || + clientVersion === '2.5' || + clientVersion === '2.2' + ) { + behaviorOptions.version = clientVersion; + } else if (clientVersion !== null && clientVersion !== undefined) { + logger?.warn( + `Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`, + ); + } + } + + if (infinispanConfig.has('cacheName')) { + behaviorOptions.cacheName = infinispanConfig.getString('cacheName'); + } + + const mediaType = infinispanConfig.getOptionalString('mediaType'); + if (mediaType === 'text/plain' || mediaType === 'application/json') { + behaviorOptions.mediaType = mediaType; + } else if (mediaType !== null && mediaType !== undefined) { + logger?.warn( + `Invalid Infinispan mediaType "${mediaType}" in config at ${storeConfigPath}.mediaType. Must be "text/plain" | "application/json". It will be ignored, and the client may use a default or fail.`, + ); + } + + if (infinispanConfig.has('maxRetries')) { + behaviorOptions.maxRetries = infinispanConfig.getNumber('maxRetries'); + } + + if (infinispanConfig.has('topologyUpdates')) { + behaviorOptions.topologyUpdates = + infinispanConfig.getBoolean('topologyUpdates'); + } + + // Default Options End... + + // Parse Authentication and SSL Options + if (infinispanConfig.has('authentication')) { + const authConfig = infinispanConfig.getConfig('authentication'); + const auth: Partial = {}; + + if (authConfig.has('enabled')) { + auth.enabled = authConfig.getBoolean('enabled'); + } + if (authConfig.has('saslMechanism')) { + auth.saslMechanism = authConfig.getString('saslMechanism'); + } + if (authConfig.has('userName')) { + auth.userName = authConfig.getString('userName'); + } + if (authConfig.has('password')) { + auth.password = authConfig.getString('password'); + } + if (authConfig.has('token')) { + auth.token = authConfig.getString('token'); + } + if (authConfig.has('authzid')) { + auth.authzid = authConfig.getString('authzid'); + } + + behaviorOptions.authentication = auth as InfinispanAuthOptions; + } + + if (infinispanConfig.has('ssl')) { + const sslConfig = infinispanConfig.getConfig('ssl'); + const ssl: Partial = {}; + + if (sslConfig.has('enabled')) { + ssl.enabled = sslConfig.getBoolean('enabled'); + } + if (sslConfig.has('secureProtocol')) { + ssl.secureProtocol = sslConfig.getString('secureProtocol'); + } + if (sslConfig.has('trustCerts')) { + ssl.trustCerts = sslConfig.getStringArray('trustCerts'); + } + if (sslConfig.has('sniHostname')) { + ssl.sniHostName = sslConfig.getString('sniHostname'); + } + + if (sslConfig.has('clientAuth')) { + const clientAuth = infinispanConfig.getConfig('clientAuth'); + const clientAuthOpts: Partial = {}; + + if (clientAuth.has('key')) { + clientAuthOpts.key = clientAuth.getString('key'); + } + if (clientAuth.has('passphrase')) { + clientAuthOpts.passphrase = clientAuth.getString('passphrase'); + } + if (clientAuth.has('cert')) { + clientAuthOpts.cert = clientAuth.getString('cert'); + } + ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions; + } + + if (sslConfig.has('cryptoStore')) { + const cryptoStore = infinispanConfig.getConfig('cryptoStore'); + const cryptoStoreOpts: Partial = {}; + + if (cryptoStore.has('path')) { + cryptoStoreOpts.key = cryptoStore.getString('path'); + } + if (cryptoStore.has('passphrase')) { + cryptoStoreOpts.passphrase = cryptoStore.getString('passphrase'); + } + ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions; + } + + behaviorOptions.ssl = ssl as InfinispanSslOptions; + } + + parsedOptions.options = behaviorOptions as InfinispanClientBehaviorOptions; + + return parsedOptions as InfinispanCacheStoreOptions; + } +} diff --git a/packages/backend-defaults/src/entrypoints/cache/types.ts b/packages/backend-defaults/src/entrypoints/cache/types.ts index d21fb5d44b..c6175e1b66 100644 --- a/packages/backend-defaults/src/entrypoints/cache/types.ts +++ b/packages/backend-defaults/src/entrypoints/cache/types.ts @@ -24,6 +24,7 @@ import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis'; * @public */ export type RedisCacheStoreOptions = { + type: 'redis' | 'valkey'; client?: KeyvRedisOptions; cluster?: RedisClusterOptions; }; @@ -33,7 +34,9 @@ export type RedisCacheStoreOptions = { * * @public */ -export type CacheStoreOptions = RedisCacheStoreOptions; +export type CacheStoreOptions = + | RedisCacheStoreOptions + | InfinispanCacheStoreOptions; /** * Options given when constructing a {@link CacheManager}. @@ -56,3 +59,95 @@ export type CacheManagerOptions = { export function ttlToMilliseconds(ttl: number | HumanDuration): number { return typeof ttl === 'number' ? ttl : durationToMilliseconds(ttl); } + +/** + * Configuration for a single Infinispan server. + */ +export type InfinispanServerConfig = { + host: string; + port: number; +}; + +/** + * Options for putting values into Infinispan cache. + */ +export type InfinispanPutOptions = { + lifespan?: string; + maxIdle?: string; + previous?: boolean; + flags?: string[]; +}; +/** + * SSL/TLS options for the Infinispan client. + */ +export type InfinispanSslOptions = { + enabled: boolean; + secureProtocol?: string; + trustCerts?: string[]; // Array of trusted CA certificates + clientAuth?: InfinispanClientAuthOptions; + cryptoStore?: InfinispanCryptoStoreOptions; + sniHostName?: string; +}; + +/** + * Authentication options for the Infinispan client. + * This is used for client-side authentication with the Infinispan server. + */ +export type InfinispanClientAuthOptions = { + key?: string; + passphrase?: string; + cert?: string; +}; + +/** + * Options for the Infinispan client crypto store. + * This is used for storing keys and certificates securely. + */ +export type InfinispanCryptoStoreOptions = { + path?: string; + passphrase?: string; +}; + +/** + * Authentication options for the Infinispan client. + */ +export type InfinispanAuthOptions = { + enabled: boolean; + saslMechanism?: string; + userName?: string; + password?: string; + token?: string; + authzid?: string; +}; + +/** + * Options for the Infinispan cache store, designed to be configured + * in app-config.yaml under `backend.cache.infinispan`. + */ +export type InfinispanCacheStoreOptions = { + type: 'infinispan'; + servers: InfinispanServerConfig | InfinispanServerConfig[]; + options?: InfinispanClientBehaviorOptions; +}; + +export type InfinispanClusterConfig = { + name?: string; + servers: InfinispanServerConfig[]; +}; + +/** + * Detailed client behavior options for the Infinispan client. + * @public + */ +export type InfinispanClientBehaviorOptions = { + version?: '2.9' | '2.5' | '2.2'; + cacheName?: string; + maxRetries?: number; + connectionTimeout?: number; + socketTimeout?: number; + authentication?: InfinispanAuthOptions; + ssl?: InfinispanSslOptions; + mediaType?: 'text/plain' | 'application/json'; + topologyUpdates?: boolean; + clusters?: InfinispanClusterConfig[]; +}; diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index b7f14a3de5..0bf0662c6e 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -69,6 +69,7 @@ "cookie": "^0.7.0", "express": "^4.17.1", "fs-extra": "^11.0.0", + "infinispan": "^0.12.0", "keyv": "^5.2.1", "knex": "^3.0.0", "mysql2": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 99c45be567..6d254abede 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2588,6 +2588,7 @@ __metadata: git-url-parse: "npm:^15.0.0" helmet: "npm:^6.0.0" http-errors: "npm:^2.0.0" + infinispan: "npm:^0.12.0" is-glob: "npm:^4.0.3" jose: "npm:^5.0.0" keyv: "npm:^5.2.1" @@ -2755,6 +2756,7 @@ __metadata: cookie: "npm:^0.7.0" express: "npm:^4.17.1" fs-extra: "npm:^11.0.0" + infinispan: "npm:^0.12.0" keyv: "npm:^5.2.1" knex: "npm:^3.0.0" mysql2: "npm:^3.0.0" @@ -25290,6 +25292,15 @@ __metadata: languageName: node linkType: hard +"buffer-xor@npm:^2.0.2": + version: 2.0.2 + resolution: "buffer-xor@npm:2.0.2" + dependencies: + safe-buffer: "npm:^5.1.1" + checksum: 10/78226fcae9f4a0b4adec69dffc049f26f6bab240dfdd1b3f6fe07c4eb6b90da202ea5c363f98af676156ee39450a06405fddd9e8965f68a5327edcc89dcbe5d0 + languageName: node + linkType: hard + "buffer@npm:5.6.0": version: 5.6.0 resolution: "buffer@npm:5.6.0" @@ -27845,6 +27856,15 @@ __metadata: languageName: node linkType: hard +"default-user-agent@npm:^1.0.0": + version: 1.0.0 + resolution: "default-user-agent@npm:1.0.0" + dependencies: + os-name: "npm:~1.0.3" + checksum: 10/b1ef07c8e7de846a66e1e120d7ba11969faa36c8db4af2317f9b64d30e7507d129e3f721c7cc3f531a1719c1ab463d830bf426fbcda87b11defe23689f4d2b60 + languageName: node + linkType: hard + "defaults@npm:^1.0.3": version: 1.0.3 resolution: "defaults@npm:1.0.3" @@ -28250,6 +28270,13 @@ __metadata: languageName: node linkType: hard +"digest-header@npm:^1.0.0": + version: 1.1.0 + resolution: "digest-header@npm:1.1.0" + checksum: 10/fadbdda75e1cc650e460c8fe2064f74c43cc005d0eab66cc390dd1ae2678cfb41f69f151323fbd3e059e28c941f1b9adc6ea4dbd9c918cb246f34a5eb8e103f0 + languageName: node + linkType: hard + "dir-glob@npm:^3.0.1": version: 3.0.1 resolution: "dir-glob@npm:3.0.1" @@ -31150,6 +31177,13 @@ __metadata: languageName: node linkType: hard +"form-data-encoder@npm:^1.7.2": + version: 1.9.0 + resolution: "form-data-encoder@npm:1.9.0" + checksum: 10/d6a684f22660e4857ef846ad8154c00c0f0e174f3edca24567ab93d9d5b5d765b2951518672db1fccc5e1f91d66bb0d9a54f99dbd9b915d204bc6887c6a0084c + languageName: node + linkType: hard + "form-data@npm:^2.3.2, form-data@npm:^2.5.0": version: 2.5.1 resolution: "form-data@npm:2.5.1" @@ -31181,7 +31215,7 @@ __metadata: languageName: node linkType: hard -"formdata-node@npm:^4.3.2": +"formdata-node@npm:^4.3.2, formdata-node@npm:^4.3.3": version: 4.4.1 resolution: "formdata-node@npm:4.4.1" dependencies: @@ -31211,6 +31245,18 @@ __metadata: languageName: node linkType: hard +"formstream@npm:^1.1.1": + version: 1.5.2 + resolution: "formstream@npm:1.5.2" + dependencies: + destroy: "npm:^1.0.4" + mime: "npm:^2.5.2" + node-hex: "npm:^1.0.1" + pause-stream: "npm:~0.0.11" + checksum: 10/d2892fa4756260733db1f7626d3845d9c2294625d3a709bd2eb3a1899e0f17ae38bd2ad3dfd1e1d11d25f1a2789869523b6a9ec8adc9d8f5b3c0d33e58f3d4c9 + languageName: node + linkType: hard + "forwarded-parse@npm:2.1.2": version: 2.1.2 resolution: "forwarded-parse@npm:2.1.2" @@ -33227,6 +33273,19 @@ __metadata: languageName: node linkType: hard +"infinispan@npm:^0.12.0": + version: 0.12.0 + resolution: "infinispan@npm:0.12.0" + dependencies: + buffer-xor: "npm:^2.0.2" + log4js: "npm:^6.4.6" + protobufjs: "npm:^7.0.0" + underscore: "npm:^1.13.3" + urllib: "npm:^3.23.0" + checksum: 10/e805772da3304b088293457bf766749f3a6574428bd8724f234a1ceb95bdbbebb36fef6154740da3b21c62a12d8b73b1ff666ce3dc8a18648f77d9416c63e0ae + languageName: node + linkType: hard + "inflight@npm:^1.0.4": version: 1.0.6 resolution: "inflight@npm:1.0.6" @@ -36802,7 +36861,7 @@ __metadata: languageName: node linkType: hard -"log4js@npm:6.9.1": +"log4js@npm:6.9.1, log4js@npm:^6.4.6": version: 6.9.1 resolution: "log4js@npm:6.9.1" dependencies: @@ -38037,7 +38096,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: @@ -38236,7 +38295,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8": +"minimist@npm:^1.1.0, minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6, minimist@npm:^1.2.8": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -39183,6 +39242,13 @@ __metadata: languageName: node linkType: hard +"node-hex@npm:^1.0.1": + version: 1.0.1 + resolution: "node-hex@npm:1.0.1" + checksum: 10/9053d532859ee7e9653972af77ac7b73edc4f13b9b53d0b96e4045e3ac78ac4460571d4b72ad31e9095be5f7d01e6fd71f268f02ad6029091f8cabae1d4ce4df + languageName: node + linkType: hard + "node-html-markdown@npm:^1.3.0": version: 1.3.0 resolution: "node-html-markdown@npm:1.3.0" @@ -40111,6 +40177,18 @@ __metadata: languageName: node linkType: hard +"os-name@npm:~1.0.3": + version: 1.0.3 + resolution: "os-name@npm:1.0.3" + dependencies: + osx-release: "npm:^1.0.0" + win-release: "npm:^1.0.0" + bin: + os-name: cli.js + checksum: 10/2fc86cc199f8b4992bb00041401c5ab0407e3069e05981f3aa3e5a44cee9b7f22c2b0f5db2c0c1d55656c519884272b5e1e55517358c2e5f728b37dd38f5af78 + languageName: node + linkType: hard + "os-tmpdir@npm:~1.0.2": version: 1.0.2 resolution: "os-tmpdir@npm:1.0.2" @@ -40118,6 +40196,17 @@ __metadata: languageName: node linkType: hard +"osx-release@npm:^1.0.0": + version: 1.1.0 + resolution: "osx-release@npm:1.1.0" + dependencies: + minimist: "npm:^1.1.0" + bin: + osx-release: cli.js + checksum: 10/48f442f836e514d08ce73ef786db8d7cf0958e8c64a04548767ddf1081454e323fa3b7b83dcf084ecf70fe304f484e6dab0fe33e80459ac0cf7d15c1bbbe9243 + languageName: node + linkType: hard + "outdent@npm:^0.5.0": version: 0.5.0 resolution: "outdent@npm:0.5.0" @@ -40848,6 +40937,15 @@ __metadata: languageName: node linkType: hard +"pause-stream@npm:~0.0.11": + version: 0.0.11 + resolution: "pause-stream@npm:0.0.11" + dependencies: + through: "npm:~2.3" + checksum: 10/1407efadfe814b5c487e4b28d6139cb7e03ee5d25fbb5f89a68f2053e81f05ce6b2bec196eeb3d46ef2c856f785016d14816b0d0e3c3abd1b64311c5c20660dc + languageName: node + linkType: hard + "pause@npm:0.0.1": version: 0.0.1 resolution: "pause@npm:0.0.1" @@ -42136,9 +42234,9 @@ __metadata: languageName: node linkType: hard -"protobufjs@npm:^7.2.5, protobufjs@npm:^7.2.6, protobufjs@npm:^7.3.0, protobufjs@npm:^7.3.2, protobufjs@npm:^7.4.0": - version: 7.4.0 - resolution: "protobufjs@npm:7.4.0" +"protobufjs@npm:^7.0.0, protobufjs@npm:^7.2.5, protobufjs@npm:^7.2.6, protobufjs@npm:^7.3.0, protobufjs@npm:^7.3.2, protobufjs@npm:^7.4.0": + version: 7.5.3 + resolution: "protobufjs@npm:7.5.3" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" @@ -42152,7 +42250,7 @@ __metadata: "@protobufjs/utf8": "npm:^1.1.0" "@types/node": "npm:>=13.7.0" long: "npm:^5.0.0" - checksum: 10/408423506610f70858d7593632f4a6aa4f05796c90fd632be9b9252457c795acc71aa6d3b54bb7f48a890141728fee4ca3906723ccea6c202ad71f21b3879b8b + checksum: 10/3e412d2e2f799875dcdac1b43508f465a499dd3ffd9d24073669702589ef016529905617a12a967b1a8cfbe803a638b494cc3ed8db035605c7570d1a7fc094c9 languageName: node linkType: hard @@ -44819,7 +44917,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^5.5.0, semver@npm:^5.6.0": +"semver@npm:^5.0.1, semver@npm:^5.5.0, semver@npm:^5.6.0": version: 5.7.2 resolution: "semver@npm:5.7.2" bin: @@ -47047,7 +47145,7 @@ __metadata: languageName: node linkType: hard -"through@npm:2, through@npm:^2.3.6": +"through@npm:2, through@npm:^2.3.6, through@npm:~2.3": version: 2.3.8 resolution: "through@npm:2.3.8" checksum: 10/5da78346f70139a7d213b65a0106f3c398d6bc5301f9248b5275f420abc2c4b1e77c2abc72d218dedc28c41efb2e7c312cb76a7730d04f9c2d37d247da3f4198 @@ -47734,10 +47832,10 @@ __metadata: languageName: node linkType: hard -"type-fest@npm:^4.26.1": - version: 4.26.1 - resolution: "type-fest@npm:4.26.1" - checksum: 10/b82676194f80af228cb852e320d2ea8381c89d667d2e4d9f2bdfc8f254bccc039c7741a90c53617a4de0c9fdca8265ed18eb0888cd628f391c5c381c33a9f94b +"type-fest@npm:^4.26.1, type-fest@npm:^4.3.1": + version: 4.41.0 + resolution: "type-fest@npm:4.41.0" + checksum: 10/617ace794ac0893c2986912d28b3065ad1afb484cad59297835a0807dc63286c39e8675d65f7de08fafa339afcb8fe06a36e9a188b9857756ae1e92ee8bda212 languageName: node linkType: hard @@ -48084,7 +48182,7 @@ __metadata: languageName: node linkType: hard -"underscore@npm:^1.12.1": +"underscore@npm:^1.12.1, underscore@npm:^1.13.3": version: 1.13.7 resolution: "underscore@npm:1.13.7" checksum: 10/1ce3368dbe73d1e99678fa5d341a9682bd27316032ad2de7883901918f0f5d50e80320ccc543f53c1862ab057a818abc560462b5f83578afe2dd8dd7f779766c @@ -48112,7 +48210,7 @@ __metadata: languageName: node linkType: hard -"undici@npm:^5.28.4": +"undici@npm:^5.28.2, undici@npm:^5.28.4": version: 5.29.0 resolution: "undici@npm:5.29.0" dependencies: @@ -48492,6 +48590,25 @@ __metadata: languageName: node linkType: hard +"urllib@npm:^3.23.0": + version: 3.27.3 + resolution: "urllib@npm:3.27.3" + dependencies: + default-user-agent: "npm:^1.0.0" + digest-header: "npm:^1.0.0" + form-data-encoder: "npm:^1.7.2" + formdata-node: "npm:^4.3.3" + formstream: "npm:^1.1.1" + mime-types: "npm:^2.1.35" + pump: "npm:^3.0.0" + qs: "npm:^6.11.2" + type-fest: "npm:^4.3.1" + undici: "npm:^5.28.2" + ylru: "npm:^1.3.2" + checksum: 10/4d0a5a7afa8ae9697a573f643851f9508cf5c5a1e7d800830870740d0561bddf3599861f228b119e95d870b1dae5fe2e1183c0840ebec001fc93e9fd0b8e1701 + languageName: node + linkType: hard + "urlpattern-polyfill@npm:^8.0.0": version: 8.0.2 resolution: "urlpattern-polyfill@npm:8.0.2" @@ -49396,6 +49513,15 @@ __metadata: languageName: node linkType: hard +"win-release@npm:^1.0.0": + version: 1.1.1 + resolution: "win-release@npm:1.1.1" + dependencies: + semver: "npm:^5.0.1" + checksum: 10/8943898cc4badaf8598342d63093e49ae9a64c140cf190e81472d3a8890f3387b8408181412e1b58658fe7777ce5d1e3f02eee4beeaee49909d1d17a72d52fc1 + languageName: node + linkType: hard + "winston-transport@npm:^4.5.0, winston-transport@npm:^4.7.0": version: 4.8.0 resolution: "winston-transport@npm:4.8.0" @@ -49815,7 +49941,7 @@ __metadata: languageName: node linkType: hard -"ylru@npm:^1.2.0": +"ylru@npm:^1.2.0, ylru@npm:^1.3.2": version: 1.4.0 resolution: "ylru@npm:1.4.0" checksum: 10/5437f8eb2fb5dd515845c657dde3cecaa9f6bd4c6386d2a5212d3fafe02189c7d8ebfdfc84940a7811607cb3524eb362ce95d3180d355cd5deb610aa8c82c9bc From 8f776ef81ae2ebed9bd481ebc973dc30d4467307 Mon Sep 17 00:00:00 2001 From: John Redwood Date: Wed, 13 Aug 2025 08:06:43 +1000 Subject: [PATCH 2/5] fix: review comments and added dataFormat options Signed-off-by: John Redwood --- packages/backend-defaults/config.d.ts | 16 ++ .../src/entrypoints/cache/CacheManager.ts | 126 ++++++----- .../infinispan/InfinispanOptionsMapper.ts | 205 ++++++++---------- .../src/entrypoints/cache/types.ts | 6 + 4 files changed, 181 insertions(+), 172 deletions(-) diff --git a/packages/backend-defaults/config.d.ts b/packages/backend-defaults/config.d.ts index 019b76f407..4fd3aca193 100644 --- a/packages/backend-defaults/config.d.ts +++ b/packages/backend-defaults/config.d.ts @@ -796,6 +796,22 @@ export interface Config { */ mediaType?: 'text/plain' | 'application/json'; + /** + * Optional data format configuration. + * If not provided, defaults to text/plain for both key and value. + */ + dataFormat?: { + /** + * Type of the key in the cache. + * @default 'text/plain' + */ + keyType?: 'text/plain' | 'application/json'; + /** + * Type of the value in the cache. + * @default 'text/plain' + */ + valueType?: 'text/plain' | 'application/json'; + }; /** * Infinispan server host and port configuration. * If this is an array, the client will connect to all servers in the list based on TOPOLOGY_AWARE routing. diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 9315593a39..1c26083525 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -261,26 +261,29 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'redis') { + throw new Error( + `Internal error: Wrong config type passed to redis factory: ${this.storeOptions?.type}`, + ); + } if (!stores[pluginId]) { - if (this.storeOptions?.type === 'redis') { - const redisOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Redis cluster - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvRedis(cluster, redisOptions); - } else { - // Create a regular Redis connection - stores[pluginId] = new KeyvRedis(this.connection, redisOptions); - } - - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create redis cache client', err); - this.errorHandler?.(err); - }); + const redisOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Redis cluster + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvRedis(cluster, redisOptions); + } else { + // Create a regular Redis connection + stores[pluginId] = new KeyvRedis(this.connection, redisOptions); } + + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create redis cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ namespace: pluginId, @@ -298,26 +301,29 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'valkey') { + throw new Error( + `Internal error: Wrong config type passed to valkey factory: ${this.storeOptions?.type}`, + ); + } if (!stores[pluginId]) { - if (this.storeOptions?.type === 'valkey') { - const valkeyOptions = this.storeOptions?.client || { - keyPrefixSeparator: ':', - }; - if (this.storeOptions?.cluster) { - // Create a Valkey cluster (Redis cluster under the hood) - const cluster = createCluster(this.storeOptions?.cluster); - stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); - } else { - // Create a regular Valkey connection - stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); - } - - // Always provide an error handler to avoid stopping the process - stores[pluginId].on('error', (err: Error) => { - this.logger?.error('Failed to create valkey cache client', err); - this.errorHandler?.(err); - }); + const valkeyOptions = this.storeOptions?.client || { + keyPrefixSeparator: ':', + }; + if (this.storeOptions?.cluster) { + // Create a Valkey cluster (Redis cluster under the hood) + const cluster = createCluster(this.storeOptions?.cluster); + stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); + } else { + // Create a regular Valkey connection + stores[pluginId] = new KeyvValkey(this.connection, valkeyOptions); } + + // Always provide an error handler to avoid stopping the process + stores[pluginId].on('error', (err: Error) => { + this.logger?.error('Failed to create valkey cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ namespace: pluginId, @@ -366,33 +372,37 @@ export class CacheManager { const stores: Record = {}; return (pluginId, defaultTtl) => { + if (this.storeOptions?.type !== 'infinispan') { + throw new Error( + `Internal error: Wrong config type passed to infinispan factory: ${this.storeOptions?.type}`, + ); + } + if (!stores[pluginId]) { - if (this.storeOptions?.type === 'infinispan') { - // Use sync version for testing environments - const isTest = - process.env.NODE_ENV === 'test' || typeof jest !== 'undefined'; + // Use sync version for testing environments + const isTest = + process.env.NODE_ENV === 'test' || typeof jest !== 'undefined'; - // Create the client promise ONCE and reuse it - const clientPromise: Promise = isTest - ? this.createInfinispanClientSync() - : this.createInfinispanClientAsync(); + // Create the client promise ONCE and reuse it + const clientPromise: Promise = isTest + ? this.createInfinispanClientSync() + : this.createInfinispanClientAsync(); - this.logger?.info( - `Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`, - ); - const storeInstance = new InfinispanKeyvStore({ - clientPromise, - logger: this.logger!, - }); + this.logger?.info( + `Creating Infinispan cache client for plugin ${pluginId} isTest = ${isTest}`, + ); + const storeInstance = new InfinispanKeyvStore({ + clientPromise, + logger: this.logger!, + }); - stores[pluginId] = storeInstance; + stores[pluginId] = storeInstance; - // Always provide an error handler to avoid stopping the process - storeInstance.on('error', (err: Error) => { - this.logger?.error('Failed to create infinispan cache client', err); - this.errorHandler?.(err); - }); - } + // Always provide an error handler to avoid stopping the process + storeInstance.on('error', (err: Error) => { + this.logger?.error('Failed to create infinispan cache client', err); + this.errorHandler?.(err); + }); } return new Keyv({ diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts index 58234bb11d..ee49ee2581 100644 --- a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts @@ -19,6 +19,7 @@ import { RootConfigService, } from '@backstage/backend-plugin-api'; import { + DataFormatOptions, InfinispanAuthOptions, InfinispanCacheStoreOptions, InfinispanClientAuthOptions, @@ -55,29 +56,23 @@ export class InfinispanOptionsMapper { serverConf => ({ host: serverConf.getString('host'), - port: serverConf.getNumber('port'), + port: serverConf.getOptionalNumber('port') ?? 11222, } as InfinispanServerConfig), ); } else if (typeof serversConfig === 'object' && serversConfig !== null) { const serverConf = infinispanConfig.getConfig('servers'); parsedOptions.servers = { - host: serverConf.getString('host'), - port: serverConf.getNumber('port'), + host: serverConf.getOptionalString('host') ?? '127.0.0.1', + port: serverConf.getOptionalString('port') ?? 11222, } as InfinispanServerConfig; } else { - logger?.error( - `Infinispan 'servers' configuration at ${storeConfigPath} must be an object or an array.`, - ); throw new Error( - `Infinispan 'servers' configuration at ${storeConfigPath} is invalid.`, + `Infinispan 'servers' configuration at ${storeConfigPath} is invalid, must be an object or an array.`, ); } } else { - logger?.error( - `Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition.`, - ); - throw new Error( - `Infinispan configuration at ${storeConfigPath} must define 'servers'.`, + logger?.warn( + `Infinispan configuration at ${storeConfigPath} is missing the 'servers' definition, will use client defaults.`, ); } @@ -85,42 +80,35 @@ export class InfinispanOptionsMapper { // This will be used to configure the Infinispan client behavior. const behaviorOptions: Partial = {}; - if (infinispanConfig.has('clusters')) { - const clustersConfig = infinispanConfig.getConfigArray('clusters'); - const clusterOptions: InfinispanClusterConfig[] = []; - clustersConfig?.forEach(clusterConf => { + behaviorOptions.clusters = infinispanConfig + .getOptionalConfigArray('clusters') + ?.map(clusterConf => { const name = clusterConf.getOptionalString('name'); - const cluster: InfinispanClusterConfig = { + return { ...(name && { name }), servers: clusterConf.getConfigArray('servers').map(serverConf => ({ host: serverConf.getString('host'), port: serverConf.getNumber('port'), })), - }; - clusterOptions.push(cluster); - behaviorOptions.clusters = clusterOptions; + } as InfinispanClusterConfig; }); - } // Parse Default Options Start... - if (infinispanConfig.has('version')) { - const clientVersion = infinispanConfig.getString('version'); - if ( - clientVersion === '2.9' || - clientVersion === '2.5' || - clientVersion === '2.2' - ) { - behaviorOptions.version = clientVersion; - } else if (clientVersion !== null && clientVersion !== undefined) { - logger?.warn( - `Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`, - ); - } + const clientVersion = + infinispanConfig.getOptionalString('version') ?? '2.9'; + if ( + clientVersion === '2.9' || + clientVersion === '2.5' || + clientVersion === '2.2' + ) { + behaviorOptions.version = clientVersion; + } else if (clientVersion !== null && clientVersion !== undefined) { + logger?.warn( + `Invalid Infinispan client version "${clientVersion}" in config at ${storeConfigPath}.version. Must be "2.9", "2.5", or "2.2". It will be ignored, and the client may use a default or fail.`, + ); } - if (infinispanConfig.has('cacheName')) { - behaviorOptions.cacheName = infinispanConfig.getString('cacheName'); - } + behaviorOptions.cacheName = infinispanConfig.getOptionalString('cacheName'); const mediaType = infinispanConfig.getOptionalString('mediaType'); if (mediaType === 'text/plain' || mediaType === 'application/json') { @@ -131,95 +119,84 @@ export class InfinispanOptionsMapper { ); } - if (infinispanConfig.has('maxRetries')) { - behaviorOptions.maxRetries = infinispanConfig.getNumber('maxRetries'); - } + behaviorOptions.maxRetries = + infinispanConfig.getOptionalNumber('maxRetries'); - if (infinispanConfig.has('topologyUpdates')) { - behaviorOptions.topologyUpdates = - infinispanConfig.getBoolean('topologyUpdates'); - } + behaviorOptions.topologyUpdates = + infinispanConfig.getOptionalBoolean('topologyUpdates') ?? true; // Default Options End... // Parse Authentication and SSL Options - if (infinispanConfig.has('authentication')) { - const authConfig = infinispanConfig.getConfig('authentication'); - const auth: Partial = {}; + const authConfig = infinispanConfig.getOptionalConfig('authentication'); + const auth: Partial = {}; - if (authConfig.has('enabled')) { - auth.enabled = authConfig.getBoolean('enabled'); - } - if (authConfig.has('saslMechanism')) { - auth.saslMechanism = authConfig.getString('saslMechanism'); - } - if (authConfig.has('userName')) { - auth.userName = authConfig.getString('userName'); - } - if (authConfig.has('password')) { - auth.password = authConfig.getString('password'); - } - if (authConfig.has('token')) { - auth.token = authConfig.getString('token'); - } - if (authConfig.has('authzid')) { - auth.authzid = authConfig.getString('authzid'); - } + auth.enabled = authConfig?.getOptionalBoolean('enabled'); + auth.saslMechanism = authConfig?.getOptionalString('saslMechanism'); + auth.userName = authConfig?.getOptionalString('userName'); + auth.password = authConfig?.getOptionalString('password'); + auth.token = authConfig?.getOptionalString('token'); + auth.authzid = authConfig?.getOptionalString('authzid'); - behaviorOptions.authentication = auth as InfinispanAuthOptions; + behaviorOptions.authentication = auth as InfinispanAuthOptions; + + const sslConfig = infinispanConfig.getOptionalConfig('ssl'); + const ssl: Partial = {}; + + ssl.enabled = sslConfig?.getOptionalBoolean('enabled'); + ssl.secureProtocol = sslConfig?.getOptionalString('secureProtocol'); + ssl.trustCerts = sslConfig?.getOptionalStringArray('trustCerts'); + ssl.sniHostName = sslConfig?.getOptionalString('sniHostname'); + + const clientAuth = infinispanConfig.getOptionalConfig('clientAuth'); + const clientAuthOpts: Partial = {}; + + clientAuthOpts.key = clientAuth?.getOptionalString('key'); + clientAuthOpts.passphrase = clientAuth?.getOptionalString('passphrase'); + clientAuthOpts.cert = clientAuth?.getOptionalString('cert'); + + ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions; + + const cryptoStore = infinispanConfig.getOptionalConfig('cryptoStore'); + const cryptoStoreOpts: Partial = {}; + + cryptoStoreOpts.key = cryptoStore?.getOptionalString('path'); + cryptoStoreOpts.passphrase = cryptoStore?.getOptionalString('passphrase'); + + ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions; + + behaviorOptions.ssl = ssl as InfinispanSslOptions; + + const dataFormat = infinispanConfig.getOptionalConfig('dataFormat'); + const dataFormatOpts: Partial = {}; + + const keyType = dataFormat?.getOptionalString('keyType') ?? 'text/plain'; + const valueType = + dataFormat?.getOptionalString('valueType') ?? 'text/plain'; + + if (keyType === 'text/plain' || keyType === 'application/json') { + dataFormatOpts.keyType = keyType; + } else if (keyType !== null && keyType !== undefined) { + logger?.warn( + `Invalid Infinispan dataFormat.keyType "${keyType}" in config at ${storeConfigPath}.dataFormat.keyType. Must be "text/plain" | "application/json". Not mapped, client will use default 'text/plain'.`, + ); } - if (infinispanConfig.has('ssl')) { - const sslConfig = infinispanConfig.getConfig('ssl'); - const ssl: Partial = {}; - - if (sslConfig.has('enabled')) { - ssl.enabled = sslConfig.getBoolean('enabled'); - } - if (sslConfig.has('secureProtocol')) { - ssl.secureProtocol = sslConfig.getString('secureProtocol'); - } - if (sslConfig.has('trustCerts')) { - ssl.trustCerts = sslConfig.getStringArray('trustCerts'); - } - if (sslConfig.has('sniHostname')) { - ssl.sniHostName = sslConfig.getString('sniHostname'); - } - - if (sslConfig.has('clientAuth')) { - const clientAuth = infinispanConfig.getConfig('clientAuth'); - const clientAuthOpts: Partial = {}; - - if (clientAuth.has('key')) { - clientAuthOpts.key = clientAuth.getString('key'); - } - if (clientAuth.has('passphrase')) { - clientAuthOpts.passphrase = clientAuth.getString('passphrase'); - } - if (clientAuth.has('cert')) { - clientAuthOpts.cert = clientAuth.getString('cert'); - } - ssl.clientAuth = clientAuthOpts as InfinispanClientAuthOptions; - } - - if (sslConfig.has('cryptoStore')) { - const cryptoStore = infinispanConfig.getConfig('cryptoStore'); - const cryptoStoreOpts: Partial = {}; - - if (cryptoStore.has('path')) { - cryptoStoreOpts.key = cryptoStore.getString('path'); - } - if (cryptoStore.has('passphrase')) { - cryptoStoreOpts.passphrase = cryptoStore.getString('passphrase'); - } - ssl.cryptoStore = cryptoStoreOpts as InfinispanClientAuthOptions; - } - - behaviorOptions.ssl = ssl as InfinispanSslOptions; + if (valueType === 'text/plain' || valueType === 'application/json') { + dataFormatOpts.valueType = valueType; + } else if (valueType !== null && valueType !== undefined) { + logger?.warn( + `Invalid Infinispan dataFormat.valueType "${valueType}" in config at ${storeConfigPath}.dataFormat.valueType. Must be "text/plain" | "application/json". Not mapped, client will use default 'text/plain'.`, + ); } - + behaviorOptions.dataFormat = dataFormatOpts as DataFormatOptions; parsedOptions.options = behaviorOptions as InfinispanClientBehaviorOptions; + logger?.debug( + `Parsed Infinispan options from config at ${storeConfigPath}: ${JSON.stringify( + parsedOptions, + )}`, + ); return parsedOptions as InfinispanCacheStoreOptions; } } diff --git a/packages/backend-defaults/src/entrypoints/cache/types.ts b/packages/backend-defaults/src/entrypoints/cache/types.ts index c6175e1b66..522ab2ac3e 100644 --- a/packages/backend-defaults/src/entrypoints/cache/types.ts +++ b/packages/backend-defaults/src/entrypoints/cache/types.ts @@ -135,6 +135,11 @@ export type InfinispanClusterConfig = { servers: InfinispanServerConfig[]; }; +export type DataFormatOptions = { + keyType: 'text/plain' | 'application/json'; + valueType: 'text/plain' | 'application/json'; +}; + /** * Detailed client behavior options for the Infinispan client. * @public @@ -150,4 +155,5 @@ export type InfinispanClientBehaviorOptions = { mediaType?: 'text/plain' | 'application/json'; topologyUpdates?: boolean; clusters?: InfinispanClusterConfig[]; + dataFormat: DataFormatOptions; }; From af42754469b3464b891320df9e420643c811ab6d Mon Sep 17 00:00:00 2001 From: John Redwood Date: Wed, 13 Aug 2025 08:41:35 +1000 Subject: [PATCH 3/5] fix: added missing default for 'cache' name Signed-off-by: John Redwood --- .../cache/providers/infinispan/InfinispanOptionsMapper.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts index ee49ee2581..f6e2b2d9df 100644 --- a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts @@ -108,7 +108,8 @@ export class InfinispanOptionsMapper { ); } - behaviorOptions.cacheName = infinispanConfig.getOptionalString('cacheName'); + behaviorOptions.cacheName = + infinispanConfig.getOptionalString('cacheName') ?? 'cache'; const mediaType = infinispanConfig.getOptionalString('mediaType'); if (mediaType === 'text/plain' || mediaType === 'application/json') { From a1398b38ad14fcbd95627a3dd6faca218b95a37a Mon Sep 17 00:00:00 2001 From: John Redwood Date: Fri, 15 Aug 2025 07:35:43 +1000 Subject: [PATCH 4/5] fix: yarnlock Signed-off-by: John Redwood --- yarn.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 6d254abede..514933979f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38096,7 +38096,7 @@ __metadata: languageName: node linkType: hard -"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.19, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": +"mime-types@npm:2.1.35, mime-types@npm:^2.1.12, mime-types@npm:^2.1.18, mime-types@npm:^2.1.27, mime-types@npm:^2.1.31, mime-types@npm:^2.1.35, mime-types@npm:~2.1.17, mime-types@npm:~2.1.24, mime-types@npm:~2.1.34": version: 2.1.35 resolution: "mime-types@npm:2.1.35" dependencies: From 2fcfbfbd2848f60d67b332278842054f18d0d476 Mon Sep 17 00:00:00 2001 From: John Redwood Date: Tue, 19 Aug 2025 19:50:05 +1000 Subject: [PATCH 5/5] fix: clean up not required content Signed-off-by: John Redwood --- .changeset/tough-clubs-pick.md | 1 - .../cache/providers/infinispan/InfinispanOptionsMapper.ts | 2 +- packages/backend-test-utils/package.json | 1 - yarn.lock | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/.changeset/tough-clubs-pick.md b/.changeset/tough-clubs-pick.md index 9b0f5c31b9..5462482d2c 100644 --- a/.changeset/tough-clubs-pick.md +++ b/.changeset/tough-clubs-pick.md @@ -1,5 +1,4 @@ --- -'@backstage/backend-test-utils': minor '@backstage/backend-defaults': minor --- diff --git a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts index f6e2b2d9df..59fd4fb28e 100644 --- a/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts +++ b/packages/backend-defaults/src/entrypoints/cache/providers/infinispan/InfinispanOptionsMapper.ts @@ -88,7 +88,7 @@ export class InfinispanOptionsMapper { ...(name && { name }), servers: clusterConf.getConfigArray('servers').map(serverConf => ({ host: serverConf.getString('host'), - port: serverConf.getNumber('port'), + port: serverConf.getOptionalNumber('port') ?? 11222, })), } as InfinispanClusterConfig; }); diff --git a/packages/backend-test-utils/package.json b/packages/backend-test-utils/package.json index 0bf0662c6e..b7f14a3de5 100644 --- a/packages/backend-test-utils/package.json +++ b/packages/backend-test-utils/package.json @@ -69,7 +69,6 @@ "cookie": "^0.7.0", "express": "^4.17.1", "fs-extra": "^11.0.0", - "infinispan": "^0.12.0", "keyv": "^5.2.1", "knex": "^3.0.0", "mysql2": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 514933979f..eef3c6ed2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2756,7 +2756,6 @@ __metadata: cookie: "npm:^0.7.0" express: "npm:^4.17.1" fs-extra: "npm:^11.0.0" - infinispan: "npm:^0.12.0" keyv: "npm:^5.2.1" knex: "npm:^3.0.0" mysql2: "npm:^3.0.0"