diff --git a/docs/env-vars.md b/docs/env-vars.md index 197470a5..4be8701f 100644 --- a/docs/env-vars.md +++ b/docs/env-vars.md @@ -67,6 +67,8 @@ Only required if `FEDIFY_KV_STORE_TYPE` is set to `redis` - `REDIS_HOST` - Redis server host - `REDIS_PORT` - Redis server port +- `REDIS_CLUSTER` - Set to `false` to connect to a standalone (single-node) Redis server, e.g. a plain `redis:alpine` container + - Default: cluster mode - `REDIS_TLS_CERT` - TLS certificate for Redis connection - Only required if Redis is configured to use TLS diff --git a/src/configuration/registrations.ts b/src/configuration/registrations.ts index 822825a0..e77f3cd0 100644 --- a/src/configuration/registrations.ts +++ b/src/configuration/registrations.ts @@ -9,7 +9,7 @@ import { asFunction, asValue, } from 'awilix'; -import Redis from 'ioredis'; +import Redis, { type Cluster } from 'ioredis'; import type { Knex } from 'knex'; import { KnexAccountRepository } from '@/account/account.repository.knex'; @@ -94,6 +94,55 @@ import { LocalStorageAdapter } from '@/storage/adapters/local-storage-adapter'; import { ImageProcessor } from '@/storage/image-processor'; import { ImageStorageService } from '@/storage/image-storage.service'; +/** + * Create a Redis connection for use as Fedify's KvStore. + * + * Defaults to a Redis Cluster connection (the historical behaviour). Set + * `REDIS_CLUSTER=false` to connect to a standalone (single-node) Redis server, + * e.g. a plain `redis:alpine` container. + */ +export function createRedisConnection(logging: Logger): Redis | Cluster { + const host = process.env.REDIS_HOST || 'localhost'; + const port = Number(process.env.REDIS_PORT) || 6379; + const tls = process.env.REDIS_TLS_CERT + ? { ca: process.env.REDIS_TLS_CERT } + : undefined; + + const retryStrategy = (times: number) => { + const delay = Math.min(times * 50, 2000); + logging.warn( + `Redis connection retry attempt ${times}, delay ${delay}ms`, + ); + return delay; + }; + + if (process.env.REDIS_CLUSTER === 'false') { + logging.info('Connecting to Redis in standalone mode'); + + return new Redis({ + host, + port, + retryStrategy, + enableOfflineQueue: true, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + tls, + }); + } + + logging.info('Connecting to Redis in cluster mode'); + + return new Redis.Cluster([{ host, port }], { + clusterRetryStrategy: retryStrategy, + enableOfflineQueue: true, + redisOptions: { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + tls, + }, + }); +} + export function registerDependencies( container: AwilixContainer, deps: { @@ -116,38 +165,8 @@ export function registerDependencies( if (kvStoreType === 'redis') { logging.info('Using Redis KvStore for Fedify'); - const host = process.env.REDIS_HOST || 'localhost'; - const port = Number(process.env.REDIS_PORT) || 6379; - - const redis = new Redis.Cluster( - [ - { - host, - port, - }, - ], - { - clusterRetryStrategy: (times: number) => { - const delay = Math.min(times * 50, 2000); - logging.warn( - `Redis connection retry attempt ${times}, delay ${delay}ms`, - ); - return delay; - }, - enableOfflineQueue: true, - redisOptions: { - maxRetriesPerRequest: 3, - enableReadyCheck: true, - tls: process.env.REDIS_TLS_CERT - ? { - ca: process.env.REDIS_TLS_CERT, - } - : undefined, - }, - }, - ); - return new RedisKvStore(redis); + return new RedisKvStore(createRedisConnection(logging)); } logging.info('Using MySQL KvStore for Fedify'); diff --git a/src/configuration/registrations.unit.test.ts b/src/configuration/registrations.unit.test.ts new file mode 100644 index 00000000..41bd67ac --- /dev/null +++ b/src/configuration/registrations.unit.test.ts @@ -0,0 +1,90 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Logger } from '@logtape/logtape'; + +const clusterConstructor = vi.fn(); +const redisConstructor = vi.fn(); + +vi.mock('ioredis', () => { + class Cluster { + constructor(...args: unknown[]) { + clusterConstructor(...args); + } + } + class Redis { + static Cluster = Cluster; + constructor(...args: unknown[]) { + redisConstructor(...args); + } + } + return { default: Redis, Cluster }; +}); + +import { createRedisConnection } from './registrations'; + +describe('createRedisConnection', () => { + const logging = { + info: vi.fn(), + warn: vi.fn(), + } as unknown as Logger; + + const originalEnv = { ...process.env }; + + beforeEach(() => { + clusterConstructor.mockClear(); + redisConstructor.mockClear(); + process.env.REDIS_HOST = 'redis-host'; + process.env.REDIS_PORT = '6380'; + delete process.env.REDIS_CLUSTER; + delete process.env.REDIS_TLS_CERT; + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('connects in cluster mode by default', () => { + createRedisConnection(logging); + + expect(clusterConstructor).toHaveBeenCalledTimes(1); + expect(redisConstructor).not.toHaveBeenCalled(); + + const nodes = clusterConstructor.mock.calls[0][0]; + expect(nodes).toEqual([{ host: 'redis-host', port: 6380 }]); + }); + + it('connects in standalone mode when REDIS_CLUSTER is "false"', () => { + process.env.REDIS_CLUSTER = 'false'; + + createRedisConnection(logging); + + expect(redisConstructor).toHaveBeenCalledTimes(1); + expect(clusterConstructor).not.toHaveBeenCalled(); + + const options = redisConstructor.mock.calls[0][0]; + expect(options.host).toBe('redis-host'); + expect(options.port).toBe(6380); + }); + + it('defaults to localhost:6379 when host/port are unset', () => { + process.env.REDIS_CLUSTER = 'false'; + delete process.env.REDIS_HOST; + delete process.env.REDIS_PORT; + + createRedisConnection(logging); + + const options = redisConstructor.mock.calls[0][0]; + expect(options.host).toBe('localhost'); + expect(options.port).toBe(6379); + }); + + it('passes the TLS certificate through when configured', () => { + process.env.REDIS_CLUSTER = 'false'; + process.env.REDIS_TLS_CERT = 'a-cert'; + + createRedisConnection(logging); + + const options = redisConstructor.mock.calls[0][0]; + expect(options.tls).toEqual({ ca: 'a-cert' }); + }); +});