From 133519ba25502b30db5b05f326bc43daf18963c1 Mon Sep 17 00:00:00 2001 From: John Redwood Date: Mon, 4 Aug 2025 22:23:27 +1000 Subject: [PATCH] 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