diff --git a/.changeset/redis-sentinel.md b/.changeset/redis-sentinel.md new file mode 100644 index 000000000..8e11b4bbd --- /dev/null +++ b/.changeset/redis-sentinel.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge': minor +'@truefoundry/trueforge-core': minor +--- + +Add Redis Sentinel + TLS support (`REDIS_*` env and Helm `externalRedis`). diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index c9721851e..dead65e93 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -38,7 +38,7 @@ docs. Postgres and Redis ship as **bundled** dependencies (the Bitnami `postgresql` and `redis` charts, pulled from the public Bitnami OCI archive and pinned by `Chart.lock`). They are enabled by default, so a basic install needs **no -required values**. The chart wires the server's `POSTGRES_*` and `REDIS_URL` +required values**. The chart wires the server's `POSTGRES_*` and `REDIS_*` env to the bundled services automatically. The Bitnami **charts** are still public, but the **container images** they @@ -212,26 +212,34 @@ externalPostgres: The server always runs peered (`STANDALONE=false`), so Redis is always required. Bundled by default (`redis.enabled=true`, **auth disabled** — fine only when Redis stays unreachable outside the cluster trust boundary). To use an -**external** Redis, set `redis.enabled=false` and provide `externalRedis.url` -as a string or `valueFrom.secretKeyRef`: +**external** Redis, set `redis.enabled=false` and provide `externalRedis.url` and/or `host` +(or Sentinel). Set `externalRedis.enabled=true` explicitly, or omit it when url/host/sentinel +is set (upgrade-compatible). Fields accept a string or `valueFrom.secretKeyRef`. When both +url and host are set, the app prefers `REDIS_URL`: ```yaml redis: enabled: false externalRedis: - url: - valueFrom: - secretKeyRef: - name: my-redis-secret - key: redis-url + enabled: true + url: redis://:password@redis-master.databases.svc:6379 + # or host + auth when url is unset: + # host: redis-master.databases.svc + # port: 6379 + # auth: + # password: + # valueFrom: + # secretKeyRef: + # name: my-redis-secret + # key: redis-password ``` `redis.nameOverride` defaults to `trueforge-redis` so bundled Redis objects do not share names with other Redis chart dependencies when this chart is a dependency of some other chart. -For passworded Redis, prefer an external instance and load `REDIS_URL` via -`valueFrom`. +For passworded Redis, prefer an external instance and set `externalRedis.url` or +`externalRedis.auth` (and TLS/Sentinel as needed) via string or `valueFrom`. ## OIDC @@ -278,7 +286,7 @@ chart does **not** create Secrets for chart-owned fields — supply Fields that accept string | `valueFrom.secretKeyRef`: `externalPostgres.host`, `externalPostgres.port`, `externalPostgres.database`, -`externalPostgres.user`, `externalPostgres.password`, `externalRedis.url`, +`externalPostgres.user`, `externalPostgres.password`, `externalRedis.url` / `host` / `auth`, `configs.oidc.clientSecret`. `configs.oidc.issuerUrl` and `clientId` are plain strings only. @@ -362,7 +370,7 @@ also sets the `/tmp` `emptyDir.sizeLimit`. - **Enable `configs.oidc`** — leaving it off grants shared admin to anyone who can reach the server. - **Replace the `apiKey` placeholder** — create a Secret for `TRUEFORGE_API_KEY` and set `apiKey.valueFrom.secretKeyRef` (do not leave `placeholder-value-please-generate-your-own`). - **Replace the bundled Postgres password** (`trueforge`) or set `postgresql.auth.existingSecret`. -- Treat bundled Redis (`redis.auth.enabled: false`) as cluster-internal only, or switch to external passworded Redis via `externalRedis.url`. +- Treat bundled Redis (`redis.auth.enabled: false`) as cluster-internal only, or switch to external passworded Redis via `externalRedis`. - Set `server.publicBaseUrl` to the real public application URL before using MCP OAuth or OIDC (include a pathname when the UI is served under a stripped prefix). - Prefer `valueFrom.secretKeyRef` for Postgres password, Redis URL, and OIDC client secret; do not commit secrets in values files. - Prefer external managed Postgres/Redis over the bundled subcharts for production HA. diff --git a/charts/trueforge/templates/NOTES.txt b/charts/trueforge/templates/NOTES.txt index ff01cb491..fcf5bee45 100644 --- a/charts/trueforge/templates/NOTES.txt +++ b/charts/trueforge/templates/NOTES.txt @@ -39,7 +39,7 @@ before any shared / public deploy. {{- if and .Values.redis.enabled (not .Values.redis.auth.enabled) }} WARNING: Bundled Redis has auth disabled. Keep it cluster-internal (NetworkPolicy) -or switch to external passworded Redis via externalRedis.url. +or switch to external passworded Redis via externalRedis (url/host/auth). {{- end }} {{- if and .Values.configs.oidc.enabled (not .Values.server.publicBaseUrl) }} diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 5e79e2332..66b521d8f 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -191,7 +191,7 @@ postgresql subchart (existingSecret override or -postgresql). {{- end }} {{/* -Bitnami redis fullname (mirrors common.names.fullname) so REDIS_URL tracks +Bitnami redis fullname (mirrors common.names.fullname) so REDIS_HOST tracks redis.nameOverride / redis.fullnameOverride. */}} {{- define "trueforge.redis.fullname" -}} @@ -399,20 +399,68 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $env = append $env (dict "name" "STANDALONE" "value" "false") -}} {{- $env = append $env (dict "name" "GRACEFUL_TIMEOUT_SECONDS" "value" (.Values.server.gracefulTimeoutSeconds | toString)) -}} +{{- if and .Values.redis.enabled .Values.externalRedis.enabled -}} +{{- fail "redis.enabled and externalRedis.enabled are mutually exclusive" -}} +{{- end -}} +{{- $externalRedis := .Values.externalRedis | default dict -}} +{{- $sentinel := $externalRedis.sentinel | default dict -}} +{{- $externalRedisActive := or $externalRedis.enabled $externalRedis.url $externalRedis.host (eq $sentinel.enabled true) -}} {{- if .Values.redis.enabled -}} {{- $env = append $env (dict "name" "REDIS_URL" "value" (include "trueforge.redis.bundledUrl" .)) -}} -{{- else -}} -{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" .Values.externalRedis.url) | fromJson) -}} -{{- $sentinel := .Values.externalRedis.sentinel | default dict -}} +{{- else if $externalRedisActive -}} +{{- if and (not $sentinel.enabled) (not $externalRedis.url) (not $externalRedis.host) -}} +{{- fail "externalRedis.url or externalRedis.host is required when using external Redis without sentinel" -}} +{{- end -}} +{{- if $externalRedis.url -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" $externalRedis.url) | fromJson) -}} +{{- end -}} +{{- if $externalRedis.host -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" $externalRedis.host) | fromJson) -}} +{{- end -}} +{{- $env = append $env (dict "name" "REDIS_PORT" "value" (($externalRedis.port | default 6379) | toString)) -}} +{{- $env = append $env (dict "name" "REDIS_DB" "value" (($externalRedis.db | default 0) | toString)) -}} +{{- $auth := $externalRedis.auth | default dict -}} +{{- if $auth.username -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_USERNAME" "field" "externalRedis.auth.username" "value" $auth.username) | fromJson) -}} +{{- end -}} +{{- if $auth.password -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_PASSWORD" "field" "externalRedis.auth.password" "value" $auth.password) | fromJson) -}} +{{- end -}} +{{- $tls := $externalRedis.tls | default dict -}} +{{- $env = append $env (dict "name" "REDIS_TLS_ENABLED" "value" (ternary "true" "false" (eq $tls.enabled true))) -}} +{{- if $tls.enabled -}} +{{- if $tls.caCert -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_CA_CERT" "field" "externalRedis.tls.caCert" "value" $tls.caCert) | fromJson) -}} +{{- end -}} +{{- if $tls.serverName -}} +{{- $env = append $env (dict "name" "REDIS_TLS_SERVERNAME" "value" $tls.serverName) -}} +{{- end -}} +{{- if $tls.cert -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_CERT" "field" "externalRedis.tls.cert" "value" $tls.cert) | fromJson) -}} +{{- end -}} +{{- if $tls.key -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_KEY" "field" "externalRedis.tls.key" "value" $tls.key) | fromJson) -}} +{{- end -}} +{{- if $tls.keyPassphrase -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_KEY_PASSPHRASE" "field" "externalRedis.tls.keyPassphrase" "value" $tls.keyPassphrase) | fromJson) -}} +{{- end -}} +{{- end -}} +{{- $env = append $env (dict "name" "REDIS_SENTINEL_ENABLED" "value" (ternary "true" "false" (eq $sentinel.enabled true))) -}} {{- if $sentinel.enabled -}} -{{- $_ := required "externalRedis.sentinel.hosts is required when externalRedis.sentinel.enabled is true" $sentinel.hosts -}} +{{- $_ := required "externalRedis.sentinel.nodes is required when externalRedis.sentinel.enabled is true" (join "," $sentinel.nodes) -}} {{- $_ := required "externalRedis.sentinel.masterName is required when externalRedis.sentinel.enabled is true" $sentinel.masterName -}} -{{- $env = append $env (dict "name" "REDIS_SENTINEL_HOSTS" "value" $sentinel.hosts) -}} +{{- $env = append $env (dict "name" "REDIS_SENTINEL_NODES" "value" (join "," $sentinel.nodes)) -}} {{- $env = append $env (dict "name" "REDIS_SENTINEL_MASTER_NAME" "value" $sentinel.masterName) -}} -{{- if $sentinel.password -}} -{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_PASSWORD" "field" "externalRedis.sentinel.password" "value" $sentinel.password) | fromJson) -}} +{{- $sentinelAuth := $sentinel.auth | default dict -}} +{{- if $sentinelAuth.username -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_USERNAME" "field" "externalRedis.sentinel.auth.username" "value" $sentinelAuth.username) | fromJson) -}} +{{- end -}} +{{- if $sentinelAuth.password -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_PASSWORD" "field" "externalRedis.sentinel.auth.password" "value" $sentinelAuth.password) | fromJson) -}} {{- end -}} {{- end -}} +{{- else -}} +{{- fail "set redis.enabled or externalRedis (enabled, url, host, or sentinel)" -}} {{- end -}} {{- if .Values.postgresql.enabled -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index e0ac6f8d8..e20459674 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -199,9 +199,9 @@ postgresql: size: 8Gi # --- Bundled Redis (Bitnami subchart) ------------------------------------------ # Rendered only when redis.enabled is true. Auth is disabled (dev default) so -# REDIS_URL is a plain redis://host:6379. Safe only while Redis stays inside -# the cluster trust boundary. For passworded Redis, disable the subchart and -# use externalRedis with a Secret. +# Auth is disabled (dev default) so REDIS_HOST points at the bundled master +# without credentials. Safe only while Redis stays inside the cluster trust +# boundary. For passworded Redis, disable the subchart and use externalRedis. redis: enabled: true architecture: standalone @@ -237,23 +237,37 @@ externalPostgres: # Modes: disable | prefer | require | verify-ca | verify-full | no-verify. sslMode: "" externalRedis: - # Full connection URL, e.g. redis://:password@redis-master.databases.svc:6379. - # string, or { valueFrom: { secretKeyRef: { name, key } } }. Prefer valueFrom in prod. + ## Use an external Redis instead of the bundled Redis (`redis.enabled` must be false). + ## Set `enabled: true`, or set url/host/sentinel (enabled is optional for upgrade compat). + enabled: false + ## Full connection URL, e.g. redis://:password@redis-master.databases.svc:6379. + ## string or valueFrom. Preferred over host when set (app REDIS_URL). url: "" - # Redis Sentinel connection settings, injected as REDIS_SENTINEL_* env. - # url: - # valueFrom: - # secretKeyRef: - # name: my-redis-secret - # key: redis-url + ## Standalone Redis host (used when url is unset; optional when sentinel.enabled). + host: "" + port: 6379 + db: 0 + auth: + ## string or valueFrom + username: "" + password: "" + tls: + enabled: false + ## Path or inline PEM + caCert: "" + serverName: "" + cert: "" + key: "" + keyPassphrase: "" sentinel: enabled: false - # Comma-separated host:port list -> REDIS_SENTINEL_HOSTS. - hosts: "" - # Monitored master name -> REDIS_SENTINEL_MASTER_NAME. + ## Array of host:port entries + nodes: [] masterName: "" - # string or valueFrom -> REDIS_SENTINEL_PASSWORD. - password: "" + auth: + ## string or valueFrom + username: "" + password: "" # Extra raw manifests deployed alongside the server, e.g. an Istio # VirtualService, a Gateway, an Ingress, or a NetworkPolicy. Each entry is a # full Kubernetes object rendered through `tpl`, so Helm templating works and diff --git a/packages/trueforge-core/src/request-reply/client.ts b/packages/trueforge-core/src/request-reply/client.ts index 213d4d76c..8a6c368e9 100644 --- a/packages/trueforge-core/src/request-reply/client.ts +++ b/packages/trueforge-core/src/request-reply/client.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { performance } from 'node:perf_hooks'; -import type { RedisClientType } from 'redis'; import { NoResponderError, RequestTimeoutError } from './errors'; +import type { RedisPeerClient } from './redisClient'; import type { JSONReply, JSONValue, PublishedRequest, RequestEnvelope } from './types'; import { jsonReplySchema } from './types'; import { heartbeatKey, replyKey, requestChannel, sleep } from './utils'; @@ -28,7 +28,7 @@ function parseReplyPayload(raw: string) { return jsonReplySchema.parse(parsed); } -async function getDelReply(redisClient: RedisClientType, rKey: string): Promise { +async function getDelReply(redisClient: RedisPeerClient, rKey: string): Promise { const raw = await redisClient.getDel(rKey); if (raw === null) { return null; @@ -48,7 +48,7 @@ export async function redisRequest({ request, options, }: { - redis: RedisClientType; + redis: RedisPeerClient; executorId: string; path: string; request: RequestEnvelope; diff --git a/packages/trueforge-core/src/request-reply/executor.ts b/packages/trueforge-core/src/request-reply/executor.ts index 6b2e774d5..6a5564bdf 100644 --- a/packages/trueforge-core/src/request-reply/executor.ts +++ b/packages/trueforge-core/src/request-reply/executor.ts @@ -1,9 +1,9 @@ import { randomUUID } from 'node:crypto'; -import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import z from 'zod'; import { extractErrorLogFields } from '../core/util/errorLogFields'; import { ReplyError } from './errors'; +import type { RedisPeerClient } from './redisClient'; import type { JSONReply, RequestHandler } from './types'; import { publishedRequestSchema } from './types'; import { heartbeatKey, requestChannel } from './utils'; @@ -44,8 +44,8 @@ export class RequestReplyExecutor { readonly executorId: string; /** `tfg:rr:req:` — the channel this executor subscribes to. */ readonly channel: string; - private readonly redis: RedisClientType; - private readonly subscriberClient: RedisClientType; + private readonly redis: RedisPeerClient; + private readonly subscriberClient: RedisPeerClient; private readonly logger: Logger; private readonly heartbeatIntervalMs: number; private readonly heartbeatTtlMs: number; @@ -74,9 +74,9 @@ export class RequestReplyExecutor { }: { executorId: string; /** Connected command client, used only for SET (reply + heartbeat). Caller owns its lifecycle. */ - redis: RedisClientType; + redis: RedisPeerClient; /** Connected client to SUBSCRIBE on (duplicate or Sentinel). Caller owns its lifecycle. */ - subscriberClient: RedisClientType; + subscriberClient: RedisPeerClient; requestHandler: RequestHandler; onError?: RequestReplyErrorHandler | undefined; logger: Logger; diff --git a/packages/trueforge-core/src/request-reply/index.ts b/packages/trueforge-core/src/request-reply/index.ts index f8937ebfc..185cf1573 100644 --- a/packages/trueforge-core/src/request-reply/index.ts +++ b/packages/trueforge-core/src/request-reply/index.ts @@ -10,6 +10,7 @@ export type { SendRequestOptions } from './client'; export { NoResponderError, ReplyError, RequestTimeoutError } from './errors'; export { RequestReplyExecutor } from './executor'; export type { RequestReplyErrorHandler, RunExecutorOptions } from './executor'; +export type { RedisPeerClient } from './redisClient'; export { RequestReplyRouter } from './router'; export type { RouteHandler } from './router'; export { jsonReplySchema } from './types'; diff --git a/packages/trueforge-core/src/request-reply/redisClient.ts b/packages/trueforge-core/src/request-reply/redisClient.ts new file mode 100644 index 000000000..71a7cf8f3 --- /dev/null +++ b/packages/trueforge-core/src/request-reply/redisClient.ts @@ -0,0 +1,4 @@ +import type { RedisClientType, RedisSentinelType } from 'redis'; + +/** Standalone or Sentinel client for request-reply / command use. */ +export type RedisPeerClient = RedisClientType | RedisSentinelType; diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 2950d0de0..a1d67e060 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -95,9 +95,33 @@ PORT=8790 # SQLITE_PATH= ## Used when STANDALONE=false (ignored in standalone). Redis connection URL for -## executor peering. Defaults to redis://localhost:6379 if unset. +## executor peering. Preferred over REDIS_HOST when set (may include userinfo). +## Required unless REDIS_HOST or Sentinel is configured. ## `docker-compose.yml` overrides this to redis://redis:6379 for the server container. REDIS_URL=redis://localhost:6379 +# Or host-based (used when REDIS_URL is unset): +# REDIS_HOST=localhost +# REDIS_PORT=6379 +# REDIS_DB=0 +# REDIS_USERNAME= +# REDIS_PASSWORD= + +## Redis TLS (STANDALONE=false only). Applies to data nodes and Sentinel sockets. +# REDIS_TLS_ENABLED=false +# REDIS_TLS_CA_CERT= +# REDIS_TLS_REJECT_UNAUTHORIZED=true +# REDIS_TLS_SERVERNAME= +# REDIS_TLS_CERT= +# REDIS_TLS_KEY= +# REDIS_TLS_KEY_PASSPHRASE= + +## Redis Sentinel (STANDALONE=false only). When enabled with nodes + master name, +## the client discovers the master via Sentinel; REDIS_USERNAME/PASSWORD apply to data nodes. +# REDIS_SENTINEL_ENABLED=false +# REDIS_SENTINEL_NODES=sentinel-0:26379,sentinel-1:26379 +# REDIS_SENTINEL_MASTER_NAME=mymaster +# REDIS_SENTINEL_USERNAME= +# REDIS_SENTINEL_PASSWORD= ## Redis request/reply peering knobs (STANDALONE=false only). All optional. ## Max ms to wait for a peer executor's reply before failing with 424. Default 60000. diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index 5e275a30f..807ab454e 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -14,11 +14,11 @@ import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import { redisRequest, RequestTimeoutError, + type RedisPeerClient, type RouteHandler as RequestReplyRouteHandler, type RequestReplyRouter, } from '@truefoundry/trueforge-core/request-reply'; import type { Context } from 'hono'; -import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { z } from 'zod'; import type { Authorizer } from '../auth/authorizer'; @@ -81,7 +81,7 @@ export interface SessionsRouterDeps { resolveSkillStore: ResolveSkillStore; resolveAgentStore: (c: Context) => IAgentStore; resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore; - redis?: RedisClientType | undefined; + redis?: RedisPeerClient | undefined; requestReplyRouter: RequestReplyRouter; resolveRequestContext: ResolveRequestContext; logger: Logger; @@ -127,7 +127,7 @@ export interface CancelTurnDeps { activeTurns: ActiveTurnRegistry; session: Pick; sessionStore: Pick; - redis?: RedisClientType | undefined; + redis?: RedisPeerClient | undefined; logger: Pick; } diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index a8c9ef36e..10bba9bfd 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -3,12 +3,11 @@ import { swaggerUI } from '@hono/swagger-ui'; import { OpenAPIHono, z } from '@hono/zod-openapi'; import type { ISessionStore, Sessions, TurnStreamingEvent } from '@truefoundry/trueforge-core/agent-session'; import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; -import type { RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; +import type { RedisPeerClient, RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; import type { Context, ErrorHandler, MiddlewareHandler } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { HTTPException } from 'hono/http-exception'; import type { Configuration } from 'openid-client'; -import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { createAgentImportRouter } from './apis/agentImport'; import { createAgentsRouter } from './apis/agents'; @@ -207,7 +206,7 @@ export interface ServerDeps { sessions: Sessions; activeTurns: ActiveTurnRegistry; /** Primary Redis client (server-owned); undefined in standalone mode. */ - redis?: RedisClientType | undefined; + redis?: RedisPeerClient | undefined; /** Request-reply dispatch table served by this replica's executor. */ requestReplyRouter: RequestReplyRouter; /** Hands out each turn's resumable event stream to the create and subscribe handlers. */ diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 770f68367..8a73dfd90 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -8,8 +8,7 @@ * * `STANDALONE` is a discriminated mode selector: * - `true` (default): SQLite only; no Redis / executor peering. - * - `false`: Postgres + Redis (defaults to local trueforge credentials / - * `redis://localhost:6379`). + * - `false`: Postgres + Redis (`REDIS_URL`, `REDIS_HOST`, or Sentinel required). */ import { existsSync } from 'node:fs'; import os from 'node:os'; @@ -38,7 +37,6 @@ const DEFAULT_POSTGRES_PASSWORD = 'trueforge'; const DEFAULT_POSTGRES_DB = 'trueforge'; const DEFAULT_POSTGRES_HOST = 'localhost'; const DEFAULT_POSTGRES_PORT = 5432; -const DEFAULT_REDIS_URL = 'redis://localhost:6379'; /** * Fixed local service credential when `STANDALONE=true` and `TRUEFORGE_API_KEY` is unset. * Local testing only — not for distributed deployments. @@ -156,6 +154,18 @@ function parsePositiveInt(options: { envKey: string; raw: string | undefined; de return value; } +function parseNonNegativeInt(options: { envKey: string; raw: string | undefined; defaultValue: number }): number { + const { envKey, raw, defaultValue } = options; + if (raw === undefined || raw.trim() === '') { + return defaultValue; + } + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) { + throw new Error(`Environment variable ${envKey} must be a non-negative integer, got "${raw}"`); + } + return value; +} + /** Parses a boolean env var; anything but `true`/`false` throws instead of reading as `false`. */ function parseBoolean(options: { envKey: string; raw: string | undefined; defaultValue: boolean }): boolean { const { envKey, raw, defaultValue } = options; @@ -286,11 +296,11 @@ function resolveCodeModeSocketParent(): string { return path.join(os.tmpdir(), 'tf_cms'); } -/** Redis peering URL for distributed mode. Env: `REDIS_URL`. */ -function resolveRedisUrl(): string { - const raw = getEnv('REDIS_URL', { defaultValue: DEFAULT_REDIS_URL }) ?? DEFAULT_REDIS_URL; - if (raw.trim() === '') { - throw new Error('Environment variable REDIS_URL must be non-empty when STANDALONE=false.'); +/** Redis peering URL for distributed mode. Env: `REDIS_URL`. Preferred over `REDIS_HOST` when set. */ +function resolveRedisUrl(): string | undefined { + const raw = getEnv('REDIS_URL'); + if (raw === undefined || raw.trim() === '') { + return undefined; } return raw; } @@ -620,8 +630,48 @@ export type DistributedServerConfiguration = SharedServerConfiguration & { * Env: `POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS`. Default 60000. */ POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: number; - /** Peering URL shared by all replicas. Env: `REDIS_URL`. Default `redis://localhost:6379`. */ - REDIS_URL: string; + /** + * Peering URL shared by all replicas. Preferred over `REDIS_HOST` when set (may include userinfo). + * Env: `REDIS_URL`. Required (with host or Sentinel) when `STANDALONE=false`. + */ + REDIS_URL: string | undefined; + /** Standalone Redis host. Used when `REDIS_URL` / Sentinel are unset. Env: `REDIS_HOST`. */ + REDIS_HOST: string | undefined; + /** Redis port. Env: `REDIS_PORT`. Default 6379. */ + REDIS_PORT: number; + /** Redis DB index. Env: `REDIS_DB`. Default 0. */ + REDIS_DB: number; + /** Redis ACL username (data nodes). Env: `REDIS_USERNAME`. */ + REDIS_USERNAME: string | undefined; + /** Redis password (data nodes). Env: `REDIS_PASSWORD`. */ + REDIS_PASSWORD: string | undefined; + /** + * Opt into Redis Sentinel. Active only when nodes + master name are also set. + * Env: `REDIS_SENTINEL_ENABLED`. Default false. + */ + REDIS_SENTINEL_ENABLED: boolean; + /** Comma-separated `host:port` Sentinel nodes. Env: `REDIS_SENTINEL_NODES`. */ + REDIS_SENTINEL_NODES: string | undefined; + /** Sentinel monitored master name. Env: `REDIS_SENTINEL_MASTER_NAME`. */ + REDIS_SENTINEL_MASTER_NAME: string | undefined; + /** Auth to Sentinel processes (not data nodes). Env: `REDIS_SENTINEL_USERNAME`. */ + REDIS_SENTINEL_USERNAME: string | undefined; + /** Auth to Sentinel processes (not data nodes). Env: `REDIS_SENTINEL_PASSWORD`. */ + REDIS_SENTINEL_PASSWORD: string | undefined; + /** Enable TLS for Redis (and Sentinel when used). Env: `REDIS_TLS_ENABLED`. Default false. */ + REDIS_TLS_ENABLED: boolean; + /** CA cert path or inline PEM. Env: `REDIS_TLS_CA_CERT`. */ + REDIS_TLS_CA_CERT: string | undefined; + /** Verify server cert. Env: `REDIS_TLS_REJECT_UNAUTHORIZED`. Default true. */ + REDIS_TLS_REJECT_UNAUTHORIZED: boolean; + /** TLS SNI server name. Env: `REDIS_TLS_SERVERNAME`. */ + REDIS_TLS_SERVERNAME: string | undefined; + /** Client cert path or inline PEM (mTLS). Env: `REDIS_TLS_CERT`. */ + REDIS_TLS_CERT: string | undefined; + /** Client key path or inline PEM (mTLS). Env: `REDIS_TLS_KEY`. */ + REDIS_TLS_KEY: string | undefined; + /** Client key passphrase. Env: `REDIS_TLS_KEY_PASSPHRASE`. */ + REDIS_TLS_KEY_PASSPHRASE: string | undefined; /** * OIDC configuration for server authentication. * Undefined means browser login is disabled. @@ -831,6 +881,43 @@ const configuration: ServerConfiguration = standalone defaultValue: 60_000, }), REDIS_URL: resolveRedisUrl(), + REDIS_HOST: getEnv('REDIS_HOST'), + REDIS_PORT: parsePositiveInt({ + envKey: 'REDIS_PORT', + raw: getEnv('REDIS_PORT'), + defaultValue: 6379, + }), + REDIS_DB: parseNonNegativeInt({ + envKey: 'REDIS_DB', + raw: getEnv('REDIS_DB'), + defaultValue: 0, + }), + REDIS_USERNAME: getEnv('REDIS_USERNAME'), + REDIS_PASSWORD: getEnv('REDIS_PASSWORD'), + REDIS_SENTINEL_ENABLED: parseBoolean({ + envKey: 'REDIS_SENTINEL_ENABLED', + raw: getEnv('REDIS_SENTINEL_ENABLED'), + defaultValue: false, + }), + REDIS_SENTINEL_NODES: getEnv('REDIS_SENTINEL_NODES'), + REDIS_SENTINEL_MASTER_NAME: getEnv('REDIS_SENTINEL_MASTER_NAME'), + REDIS_SENTINEL_USERNAME: getEnv('REDIS_SENTINEL_USERNAME'), + REDIS_SENTINEL_PASSWORD: getEnv('REDIS_SENTINEL_PASSWORD'), + REDIS_TLS_ENABLED: parseBoolean({ + envKey: 'REDIS_TLS_ENABLED', + raw: getEnv('REDIS_TLS_ENABLED'), + defaultValue: false, + }), + REDIS_TLS_CA_CERT: getEnv('REDIS_TLS_CA_CERT'), + REDIS_TLS_REJECT_UNAUTHORIZED: parseBoolean({ + envKey: 'REDIS_TLS_REJECT_UNAUTHORIZED', + raw: getEnv('REDIS_TLS_REJECT_UNAUTHORIZED'), + defaultValue: true, + }), + REDIS_TLS_SERVERNAME: getEnv('REDIS_TLS_SERVERNAME'), + REDIS_TLS_CERT: getEnv('REDIS_TLS_CERT'), + REDIS_TLS_KEY: getEnv('REDIS_TLS_KEY'), + REDIS_TLS_KEY_PASSPHRASE: getEnv('REDIS_TLS_KEY_PASSPHRASE'), OIDC: resolveOIDCConfig(), TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL: getEnv('TRUEFOUNDRY_SERVICEFOUNDRY_SERVER_URL', { required: false }), TRUEFOUNDRY_API_KEY: getEnv('TRUEFOUNDRY_API_KEY', { required: false }), diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index a08c8f38e..d4b00107f 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -51,9 +51,12 @@ import { type ISessionStore, type TurnStreamingEvent, } from '@truefoundry/trueforge-core/agent-session'; -import { RequestReplyExecutor, RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; +import { + RequestReplyExecutor, + RequestReplyRouter, + type RedisPeerClient, +} from '@truefoundry/trueforge-core/request-reply'; import type { Kysely, Transaction } from 'kysely'; -import type { RedisClientType } from 'redis'; import type { Logger } from 'winston'; import { createServerApp } from './app'; @@ -86,6 +89,7 @@ import type { IOAuthTokenStore } from './mcp/auth/types'; import { PACKAGE_VERSION } from './packageVersion'; import { ActiveTurnRegistry } from './runtime/activeTurns'; import { EventSubscriptionRegistry } from './runtime/event-subscription'; +import { isStandaloneRedisClient } from './runtime/redis'; import { printStandaloneStartupBanner } from './startupBanner'; import { parsePerServerMcpHeaders, @@ -124,7 +128,7 @@ interface ServerPersistence { agentStore: IAgentStore; turnSkillsResolverStore: Pick, 'resolveTurnSkills'>; destroyDb: () => Promise; - redis: RedisClientType | undefined; + redis: RedisPeerClient | undefined; /** One shared client for TrueFoundry store resolvers + auth; undefined when TrueFoundry mode is off. */ serviceFoundryClient: TrueFoundryServiceFoundryServerClient | undefined; } @@ -353,6 +357,23 @@ async function createDistributedPersistence(options: { POSTGRES_STATEMENT_TIMEOUT_MS: statementTimeoutMs, POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT_MS: idleInTransactionSessionTimeoutMs, REDIS_URL: redisUrl, + REDIS_HOST: redisHost, + REDIS_PORT: redisPort, + REDIS_DB: redisDb, + REDIS_USERNAME: redisUsername, + REDIS_PASSWORD: redisPassword, + REDIS_SENTINEL_ENABLED: redisSentinelEnabled, + REDIS_SENTINEL_NODES: redisSentinelNodes, + REDIS_SENTINEL_MASTER_NAME: redisSentinelMasterName, + REDIS_SENTINEL_USERNAME: redisSentinelUsername, + REDIS_SENTINEL_PASSWORD: redisSentinelPassword, + REDIS_TLS_ENABLED: redisTlsEnabled, + REDIS_TLS_CA_CERT: redisTlsCaCert, + REDIS_TLS_REJECT_UNAUTHORIZED: redisTlsRejectUnauthorized, + REDIS_TLS_SERVERNAME: redisTlsServerName, + REDIS_TLS_CERT: redisTlsCert, + REDIS_TLS_KEY: redisTlsKey, + REDIS_TLS_KEY_PASSPHRASE: redisTlsKeyPassphrase, EXECUTOR_ID: executorId, } = configuration; @@ -467,7 +488,31 @@ async function createDistributedPersistence(options: { agentStore, turnSkillsResolverStore, destroyDb: () => db.destroy(), - redis: await connectRedis({ url: redisUrl, logger }), + redis: await connectRedis({ + url: redisUrl, + host: redisHost, + port: redisPort, + database: redisDb, + username: redisUsername, + password: redisPassword, + logger, + sentinel: { + enabled: redisSentinelEnabled, + nodes: redisSentinelNodes, + masterName: redisSentinelMasterName, + username: redisSentinelUsername, + password: redisSentinelPassword, + }, + tls: { + enabled: redisTlsEnabled, + caCert: redisTlsCaCert, + rejectUnauthorized: redisTlsRejectUnauthorized, + serverName: redisTlsServerName, + cert: redisTlsCert, + key: redisTlsKey, + keyPassphrase: redisTlsKeyPassphrase, + }, + }), serviceFoundryClient, }; } @@ -636,19 +681,25 @@ try { } // After createServerApp so every request-reply route is registered before - // the executor starts consuming messages. The executor needs a dedicated + // the executor starts consuming messages. Standalone Redis needs a dedicated // subscriber connection (a subscribed client cannot issue normal commands); - // this process owns its lifecycle. Connect before init() so init() awaits - // the initial subscribe + heartbeat — the replica is reachable for peering - // before the HTTP server starts. - let requestReplySubscriber: RedisClientType | undefined; + // Sentinel owns pub/sub on the shared client. Connect before init() so init() + // awaits the initial subscribe + heartbeat — the replica is reachable for + // peering before the HTTP server starts. + let requestReplySubscriber: RedisPeerClient | undefined; + let requestReplySubscriberOwned = false; let requestReplyExecutor: RequestReplyExecutor | undefined; if (redis) { - requestReplySubscriber = redis.duplicate(); - requestReplySubscriber.on('error', (error: Error) => { - logger.error('[RedisSubscriber] Client error', extractErrorLogFields(error)); - }); - await requestReplySubscriber.connect(); + if (isStandaloneRedisClient(redis)) { + requestReplySubscriber = redis.duplicate(); + requestReplySubscriberOwned = true; + requestReplySubscriber.on('error', (error: Error) => { + logger.error('[RedisSubscriber] Client error', extractErrorLogFields(error)); + }); + await requestReplySubscriber.connect(); + } else { + requestReplySubscriber = redis; + } requestReplyExecutor = new RequestReplyExecutor({ executorId: configuration.EXECUTOR_ID, redis, @@ -722,11 +773,14 @@ try { await activeTurns.shutdownAndWait(CancellationReason.Abandoned); await closed; // Stop serving peer requests (waits for in-flight replies), then close - // the clients this process owns: the subscriber duplicate and the primary. + // clients this process owns: the subscriber duplicate (standalone only) + // and the primary. await requestReplyExecutor?.drain(); - await requestReplySubscriber?.close().catch((error: unknown) => { - logger.warn('[Redis] Error closing subscriber client during shutdown', extractErrorLogFields(error)); - }); + if (requestReplySubscriberOwned) { + await requestReplySubscriber?.close().catch((error: unknown) => { + logger.warn('[Redis] Error closing subscriber client during shutdown', extractErrorLogFields(error)); + }); + } await redis?.close().catch((error: unknown) => { logger.warn('[Redis] Error closing client during shutdown', extractErrorLogFields(error)); }); diff --git a/packages/trueforge/src/runtime/event-subscription/index.ts b/packages/trueforge/src/runtime/event-subscription/index.ts index 0485b4730..4dccb9cce 100644 --- a/packages/trueforge/src/runtime/event-subscription/index.ts +++ b/packages/trueforge/src/runtime/event-subscription/index.ts @@ -1,4 +1,4 @@ -import type { RedisClientType } from 'redis'; +import type { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; import { InMemoryEventStreamStore, InMemoryEventSubscription } from './inMemory'; import { RedisEventSubscription } from './redis'; @@ -47,7 +47,7 @@ export class EventSubscriptionRegistry { /** One store for the whole process so producers and subscribers share streams. */ private readonly memoryStore = new InMemoryEventStreamStore(); - constructor(private readonly redis: RedisClientType | undefined) {} + constructor(private readonly redis: RedisPeerClient | undefined) {} get(streamId: string): EventSubscription { if (this.redis) { diff --git a/packages/trueforge/src/runtime/event-subscription/redis.ts b/packages/trueforge/src/runtime/event-subscription/redis.ts index 54b2bb8ff..b367c81f3 100644 --- a/packages/trueforge/src/runtime/event-subscription/redis.ts +++ b/packages/trueforge/src/runtime/event-subscription/redis.ts @@ -1,5 +1,5 @@ +import type { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; import { setTimeout as sleep } from 'node:timers/promises'; -import type { RedisClientType } from 'redis'; import { StreamGoneError, SUBSCRIBE_STREAM_THRESHOLD_MS, @@ -31,7 +31,7 @@ export class RedisEventSubscription implements EventSubscripti private nextSequenceNumber = 1; constructor( - private readonly redis: RedisClientType, + private readonly redis: RedisPeerClient, private readonly streamId: string, ) {} diff --git a/packages/trueforge/src/runtime/redis.ts b/packages/trueforge/src/runtime/redis.ts index 3710c4db7..0deaeff62 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -1,21 +1,285 @@ /** * Primary Redis connection, owned by the server: created and connected at * boot, closed last during shutdown. Injected into the request-reply - * transport (which duplicates it only for its subscriber). + * transport (which duplicates it only for its subscriber in standalone mode; + * Sentinel shares the same client for pub/sub). */ +import { existsSync, readFileSync } from 'node:fs'; + import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; -import { createClient, type RedisClientType } from 'redis'; +import type { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; +import { createClient, createSentinel, type RedisClientType } from 'redis'; import type { Logger } from 'winston'; -export async function connectRedis(input: { url: string; logger: Logger }): Promise { +const DEFAULT_SENTINEL_PORT = 26379; +const DEFAULT_REDIS_PORT = 6379; +const DEFAULT_REDIS_DB = 0; +const CONNECT_TIMEOUT_MS = 20_000; +const PING_INTERVAL_MS = 5_000; +const SENTINEL_RETRY_BASE_MS = 200; +const SENTINEL_RETRY_MAX_MS = 3_000; + +/** Parse comma-separated `host:port` list into Sentinel root nodes. */ +export function parseRedisSentinelNodes(raw: string): { host: string; port: number }[] { + return raw + .split(',') + .map(entry => entry.trim()) + .filter(Boolean) + .map(entry => { + const lastColon = entry.lastIndexOf(':'); + if (lastColon === -1) { + return { host: entry, port: DEFAULT_SENTINEL_PORT }; + } + const host = entry.slice(0, lastColon); + const parsedPort = Number.parseInt(entry.slice(lastColon + 1), 10); + return { host, port: Number.isNaN(parsedPort) ? DEFAULT_SENTINEL_PORT : parsedPort }; + }); +} + +/** Sentinel is active only when explicitly enabled and fully configured. */ +export function isRedisSentinelConfigured( + input: + | { + enabled: boolean | undefined; + nodes: string | undefined; + masterName: string | undefined; + } + | undefined, +): boolean { + return !!( + input?.enabled && + input.masterName?.trim() && + input.nodes?.trim() && + parseRedisSentinelNodes(input.nodes).length + ); +} + +export interface RedisTlsInput { + enabled: boolean | undefined; + caCert: string | undefined; + rejectUnauthorized: boolean | undefined; + serverName: string | undefined; + cert: string | undefined; + key: string | undefined; + keyPassphrase: string | undefined; +} + +export function isStandaloneRedisClient(client: RedisPeerClient): client is RedisClientType { + return 'duplicate' in client; +} + +const PEM_MARKER = '-----BEGIN'; + +function resolvePemMaterial(value: string, label: string): string { + if (value.includes(PEM_MARKER)) { + return value; + } + if (existsSync(value)) { + try { + return readFileSync(value, 'utf8'); + } catch (error) { + throw new Error( + `[Redis] Failed to read ${label} from path "${value}": ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } + } + throw new Error(`[Redis] ${label} is neither a readable file path nor inline PEM (no "${PEM_MARKER}" marker found).`); +} + +/** Shared TLS socket options for data nodes and Sentinel clients. */ +function buildTlsSocketOptions(tls: RedisTlsInput | undefined): + | { + tls: true; + rejectUnauthorized: boolean; + ca?: string; + cert?: string; + key?: string; + passphrase?: string; + servername?: string; + } + | undefined { + if (!tls?.enabled) { + return undefined; + } + if ((tls.cert && !tls.key) || (tls.key && !tls.cert)) { + throw new Error( + '[Redis] mTLS misconfigured: REDIS_TLS_CERT and REDIS_TLS_KEY must be set together ' + + '(provide both for mutual TLS, or neither).', + ); + } + + return { + tls: true, + rejectUnauthorized: tls.rejectUnauthorized ?? true, + ...(tls.caCert ? { ca: resolvePemMaterial(tls.caCert, 'REDIS_TLS_CA_CERT') } : {}), + ...(tls.cert ? { cert: resolvePemMaterial(tls.cert, 'REDIS_TLS_CERT') } : {}), + ...(tls.key ? { key: resolvePemMaterial(tls.key, 'REDIS_TLS_KEY') } : {}), + ...(tls.keyPassphrase ? { passphrase: tls.keyPassphrase } : {}), + ...(tls.serverName ? { servername: tls.serverName } : {}), + }; +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Connect via Redis Sentinel with rebuild-and-retry. + * + * A standalone `createClient()` can recover from later socket drops via the + * library's automatic reconnect. A Sentinel client cannot: if the first + * `connect()` rejects, the instance is wedged (retrying `connect()` throws + * "already attempting to open"). Discard and rebuild on each failed attempt; + * only auth failures (`WRONGPASS` / `NOAUTH`) abort permanently. + */ +async function connectSentinelWithRetry(input: { + sentinel: { + nodes: string | undefined; + masterName: string | undefined; + username: string | undefined; + password: string | undefined; + }; + auth: { database: number; username?: string; password?: string }; + clientDefaults: { disableOfflineQueue: true; pingInterval: number }; + socketTls: + | { + tls: true; + rejectUnauthorized: boolean; + ca?: string; + cert?: string; + key?: string; + passphrase?: string; + servername?: string; + } + | undefined; + logger: Logger; +}): Promise> { + let attempt = 0; + for (;;) { + const client = createSentinel({ + name: input.sentinel.masterName?.trim() ?? '', + sentinelRootNodes: parseRedisSentinelNodes(input.sentinel.nodes ?? ''), + nodeClientOptions: { + ...input.clientDefaults, + ...input.auth, + socket: { connectTimeout: CONNECT_TIMEOUT_MS, ...(input.socketTls ?? {}) }, + }, + sentinelClientOptions: { + socket: { connectTimeout: CONNECT_TIMEOUT_MS, ...(input.socketTls ?? {}) }, + ...(input.sentinel.username ? { username: input.sentinel.username } : {}), + ...(input.sentinel.password ? { password: input.sentinel.password } : {}), + }, + }); + // Without an 'error' listener node-redis crashes the process on emit. + client.on('error', (error: Error) => { + input.logger.error('[Redis] Client error', extractErrorLogFields(error)); + }); + try { + await client.connect(); + return client; + } catch (error) { + attempt += 1; + try { + await client.close(); + } catch { + // ignore — may never have opened + } + const message = error instanceof Error ? error.message : String(error); + if (message.includes('WRONGPASS') || message.includes('NOAUTH')) { + throw new Error(`[Redis] Sentinel connect failed: ${message}`, { cause: error }); + } + input.logger.error('[Redis] Sentinel connect failed, rebuilding and retrying', { + attempt, + ...extractErrorLogFields(error), + }); + await sleep(Math.min(SENTINEL_RETRY_BASE_MS * 2 ** attempt, SENTINEL_RETRY_MAX_MS)); + } + } +} + +export async function connectRedis(input: { + /** Preferred when set (may include userinfo). Env: `REDIS_URL`. */ + url: string | undefined; + /** Used when `url` / Sentinel are unset. Env: `REDIS_HOST`. */ + host: string | undefined; + /** Host mode only. Defaults to 6379. */ + port: number | undefined; + /** Host / Sentinel mode only. Defaults to 0. */ + database: number | undefined; + username: string | undefined; + password: string | undefined; + logger: Logger; + sentinel: + | { + enabled: boolean | undefined; + nodes: string | undefined; + masterName: string | undefined; + username: string | undefined; + password: string | undefined; + } + | undefined; + tls: RedisTlsInput | undefined; +}): Promise { input.logger.info('Connecting to Redis'); - const client: RedisClientType = createClient({ url: input.url }); - // Without an 'error' listener node-redis crashes the process on emit; - // reconnects are automatic, so log and keep running. - client.on('error', (error: Error) => { - input.logger.error('[Redis] Client error', extractErrorLogFields(error)); - }); - await client.connect(); + + const socketTls = buildTlsSocketOptions(input.tls); + const auth = { + database: input.database ?? DEFAULT_REDIS_DB, + ...(input.username ? { username: input.username } : {}), + ...(input.password ? { password: input.password } : {}), + }; + const clientDefaults = { + disableOfflineQueue: true, + pingInterval: PING_INTERVAL_MS, + } as const; + const url = input.url?.trim(); + + let client: RedisPeerClient; + if (isRedisSentinelConfigured(input.sentinel) && input.sentinel) { + // createSentinel()'s return is not assignable to RedisSentinelType under exactOptionalPropertyTypes + // @ts-expect-error TS2375 + client = await connectSentinelWithRetry({ + sentinel: input.sentinel, + auth, + clientDefaults, + socketTls, + logger: input.logger, + }); + } else if (url) { + client = createClient({ + url, + ...clientDefaults, + socket: { connectTimeout: CONNECT_TIMEOUT_MS, ...(socketTls ?? {}) }, + }); + // Without an 'error' listener node-redis crashes the process on emit. + client.on('error', (error: Error) => { + input.logger.error('[Redis] Client error', extractErrorLogFields(error)); + }); + await client.connect(); + } else if (input.host?.trim()) { + client = createClient({ + ...clientDefaults, + ...auth, + socket: { + host: input.host.trim(), + port: input.port ?? DEFAULT_REDIS_PORT, + connectTimeout: CONNECT_TIMEOUT_MS, + ...(socketTls ?? {}), + }, + }); + // Without an 'error' listener node-redis crashes the process on emit. + client.on('error', (error: Error) => { + input.logger.error('[Redis] Client error', extractErrorLogFields(error)); + }); + await client.connect(); + } else { + throw new Error( + '[Redis] No connection configured: set REDIS_URL, REDIS_HOST, or Redis Sentinel ' + + '(REDIS_SENTINEL_ENABLED with nodes and master name).', + ); + } + input.logger.info('Connected to Redis'); return client; }