From 8cc30c8a9cc91a5941ad4f4564046e1b6478beeb Mon Sep 17 00:00:00 2001 From: thesujai Date: Wed, 16 Sep 2026 17:53:47 +0530 Subject: [PATCH 01/17] feat: add Redis Sentinel and TLS support in TrueForge --- .changeset/redis-sentinel.md | 6 + charts/trueforge/README.md | 30 +- charts/trueforge/templates/NOTES.txt | 2 +- charts/trueforge/templates/_helpers.tpl | 60 +++- charts/trueforge/values.yaml | 45 ++- .../src/request-reply/client.ts | 6 +- .../src/request-reply/executor.ts | 10 +- .../trueforge-core/src/request-reply/index.ts | 1 + .../src/request-reply/redisClient.ts | 4 + packages/trueforge/.env.example | 26 +- packages/trueforge/src/apis/sessions.ts | 6 +- packages/trueforge/src/app.ts | 5 +- packages/trueforge/src/config.ts | 107 ++++++- packages/trueforge/src/main.ts | 90 ++++-- .../src/runtime/event-subscription/index.ts | 4 +- .../src/runtime/event-subscription/redis.ts | 4 +- packages/trueforge/src/runtime/redis.ts | 282 +++++++++++++++++- 17 files changed, 595 insertions(+), 93 deletions(-) create mode 100644 .changeset/redis-sentinel.md create mode 100644 packages/trueforge-core/src/request-reply/redisClient.ts 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 4d32b9f63..ad89c846a 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 @@ -213,26 +213,32 @@ 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 `externalRedis.enabled=true` with `url` and/or `host` (or Sentinel) +as a string or `valueFrom.secretKeyRef`. When both 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 @@ -308,7 +314,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. @@ -395,7 +401,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 13e1114df..038303d86 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 0bf3f36cf..55a8d7c9a 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -209,7 +209,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" -}} @@ -433,21 +433,67 @@ 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 -}} {{- 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) -}} +{{- else if .Values.externalRedis.enabled -}} {{- $sentinel := .Values.externalRedis.sentinel | default dict -}} +{{- if and (not $sentinel.enabled) (not .Values.externalRedis.url) (not .Values.externalRedis.host) -}} +{{- fail "externalRedis.url or externalRedis.host is required when externalRedis.enabled is true and sentinel is disabled" -}} +{{- end -}} +{{- if .Values.externalRedis.url -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" .Values.externalRedis.url) | fromJson) -}} +{{- end -}} +{{- if .Values.externalRedis.host -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" .Values.externalRedis.host) | fromJson) -}} +{{- $env = append $env (dict "name" "REDIS_PORT" "value" (.Values.externalRedis.port | toString)) -}} +{{- $env = append $env (dict "name" "REDIS_DB" "value" (.Values.externalRedis.db | toString)) -}} +{{- end -}} +{{- $auth := .Values.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 := .Values.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" -}} +{{- end -}} {{- if .Values.postgresql.enabled -}} {{- $env = append $env (dict "name" "POSTGRES_HOST" "value" (include "trueforge.postgres.host" .)) -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index ff99c111f..a0506c1a1 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -226,9 +226,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 @@ -271,23 +271,36 @@ externalPostgres: sslKeyPath: "" sslCaPath: "" 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). + 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: "" # --- Bundled dev sandbox server (tfy-sandbox-server subchart) ------------------- # Managed by the parent chart; off for standalone installs. When true, the # subchart is deployed and the app gets TRUEFOUNDRY_SANDBOX_* env pointing at it. diff --git a/packages/trueforge-core/src/request-reply/client.ts b/packages/trueforge-core/src/request-reply/client.ts index 99d9fee5b..f166af3c0 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 50be2adcb..11427ab2d 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -56,9 +56,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 37083e58b..8fb7fa2ac 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'; @@ -80,7 +80,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; @@ -126,7 +126,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 4b1255048..a4a679440 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, readFileSync } from 'node:fs'; import os from 'node:os'; @@ -40,7 +39,6 @@ const DEFAULT_POSTGRES_HOST = 'localhost'; const DEFAULT_POSTGRES_PORT = 5432; /** Default Postgres schema for app tables + Kysely migration bookkeeping. */ export const DEFAULT_POSTGRES_SCHEMA = 'trueforge'; -const DEFAULT_REDIS_URL = 'redis://localhost:6379'; /** Unquoted Postgres identifier: letter/underscore start, then alnum/underscore, ≤63 chars. */ const POSTGRES_SCHEMA_NAME_RE = /^[a-z_][a-z0-9_]{0,62}$/; /** @@ -214,6 +212,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; @@ -349,11 +359,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; } @@ -738,8 +748,48 @@ export type DistributedServerConfiguration = SharedServerConfiguration & { * Env: `POSTGRES_SCHEMA`. Default `trueforge`. */ POSTGRES_SCHEMA: string; - /** 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. @@ -984,6 +1034,43 @@ const configuration: ServerConfiguration = standalone }), POSTGRES_SCHEMA: parsePostgresSchema(getEnv('POSTGRES_SCHEMA')), 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(), AUTOMATICALLY_MOVE_TRUEFORGE_TABLES_FROM_PUBLIC_TO_TRUEFORGE_SCHEMA: parseBoolean({ envKey: 'AUTOMATICALLY_MOVE_TRUEFORGE_TABLES_FROM_PUBLIC_TO_TRUEFORGE_SCHEMA', diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 321d51979..39512348d 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -56,9 +56,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'; @@ -91,6 +94,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 { InlineMcpServerStore } from './truefoundry/InlineMcpServerStore'; import { parseInlineMcpServers, parseInlineSkills, X_TFG_MCP, X_TFG_SKILLS } from './truefoundry/inlineResources'; @@ -132,7 +136,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; } @@ -362,6 +366,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; @@ -477,7 +498,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, }; } @@ -664,19 +709,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, @@ -750,11 +801,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..7f8360e42 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -1,21 +1,283 @@ /** * 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) { + 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; } From 00ac3635a0909611d152fa61c0e57d56beda36a5 Mon Sep 17 00:00:00 2001 From: thesujai Date: Wed, 16 Sep 2026 18:27:32 +0530 Subject: [PATCH 02/17] fix: update return type of connectSentinelWithRetry to match createSentinel and add TypeScript error handling --- packages/trueforge/src/runtime/redis.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/trueforge/src/runtime/redis.ts b/packages/trueforge/src/runtime/redis.ts index 7f8360e42..0deaeff62 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -154,7 +154,7 @@ async function connectSentinelWithRetry(input: { } | undefined; logger: Logger; -}): Promise { +}): Promise> { let attempt = 0; for (;;) { const client = createSentinel({ @@ -237,6 +237,8 @@ export async function connectRedis(input: { 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, From 55f843dce51af0d46bd54e092a637e23714cf0eb Mon Sep 17 00:00:00 2001 From: thesujai Date: Wed, 16 Sep 2026 18:49:18 +0530 Subject: [PATCH 03/17] docs: update README and values.yaml for external Redis configuration and improve error handling in helpers --- charts/trueforge/README.md | 6 ++++-- charts/trueforge/templates/_helpers.tpl | 28 +++++++++++++------------ charts/trueforge/values.yaml | 1 + 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index ad89c846a..b5157768a 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -213,8 +213,10 @@ 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 `externalRedis.enabled=true` with `url` and/or `host` (or Sentinel) -as a string or `valueFrom.secretKeyRef`. When both are set, the app prefers `REDIS_URL`: +**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: diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 55a8d7c9a..ff86b05c5 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -436,29 +436,31 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- 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 if .Values.externalRedis.enabled -}} -{{- $sentinel := .Values.externalRedis.sentinel | default dict -}} -{{- if and (not $sentinel.enabled) (not .Values.externalRedis.url) (not .Values.externalRedis.host) -}} -{{- fail "externalRedis.url or externalRedis.host is required when externalRedis.enabled is true and sentinel is disabled" -}} +{{- 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 .Values.externalRedis.url -}} -{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" .Values.externalRedis.url) | fromJson) -}} +{{- if $externalRedis.url -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" $externalRedis.url) | fromJson) -}} {{- end -}} -{{- if .Values.externalRedis.host -}} -{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" .Values.externalRedis.host) | fromJson) -}} -{{- $env = append $env (dict "name" "REDIS_PORT" "value" (.Values.externalRedis.port | toString)) -}} -{{- $env = append $env (dict "name" "REDIS_DB" "value" (.Values.externalRedis.db | toString)) -}} +{{- if $externalRedis.host -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" $externalRedis.host) | fromJson) -}} {{- end -}} -{{- $auth := .Values.externalRedis.auth | default dict -}} +{{- $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 := .Values.externalRedis.tls | default dict -}} +{{- $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 -}} @@ -492,7 +494,7 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- end -}} {{- else -}} -{{- fail "set redis.enabled or externalRedis.enabled" -}} +{{- 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 a0506c1a1..309d619c1 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -272,6 +272,7 @@ externalPostgres: sslCaPath: "" externalRedis: ## 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). From 6c48eea8bbcd6078e420f2d57946f677b84cdc78 Mon Sep 17 00:00:00 2001 From: "trueforge-dev-bot[bot]" Date: Tue, 22 Sep 2026 06:18:14 +0000 Subject: [PATCH 04/17] Regenerate OpenAPI document and SDKs --- .github/fern/openapi/openapi.json | 2 +- docs/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/fern/openapi/openapi.json b/.github/fern/openapi/openapi.json index b9d8e2deb..9190f254e 100644 --- a/.github/fern/openapi/openapi.json +++ b/.github/fern/openapi/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": { diff --git a/docs/openapi.json b/docs/openapi.json index b9d8e2deb..9190f254e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -5816,7 +5816,7 @@ "info": { "description": "HTTP API for the TrueForge agent server (`/api/v1`). Interactive docs are served at `/api/v1/docs` (OpenAPI JSON at `/api/v1/openapi.json`).\n\n**Authentication:** Standalone auth accepts requests without credentials — middleware stamps a local default user. When OIDC or TrueFoundry auth is configured, protected routes require a valid cookie or `Authorization: Bearer` token. There is no built-in API-key scheme; pass custom headers only if your reverse proxy or IdP layer requires them.\n\nCovers DB-backed sessions, the agent registry, settings catalogs, and model/MCP/skill/sandbox providers.", "title": "TrueForge API", - "version": "0.2.0" + "version": "0.2.1" }, "openapi": "3.1.0", "paths": { From 46d54ba61689135fdb5b15ae0d198287d997ca1e Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Tue, 22 Sep 2026 17:52:27 +0530 Subject: [PATCH 05/17] chart: fail on external Redis combinations the app ignores or rejects - redis.enabled now conflicts with any external Redis signal (url/host/ sentinel), not just externalRedis.enabled, so a configured external Redis can no longer be silently shadowed by the bundled one. - Fail when tls.enabled is set with a redis:// url: node-redis throws a TypeError at client creation on the scheme mismatch, which would otherwise surface as a crash loop. - Fail when auth or a non-zero db accompany a url without Sentinel: the app prefers the url and silently drops them, connecting unauthenticated. - Normalize tls.enabled / sentinel.enabled through toString so quoted "true" values enable the feature instead of silently rendering "false". Signed-off-by: Raman Tehlan --- charts/trueforge/templates/_helpers.tpl | 30 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index ff86b05c5..b1a108c96 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -433,18 +433,28 @@ 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) -}} +{{- $auth := $externalRedis.auth | default dict -}} +{{- $tls := $externalRedis.tls | default dict -}} +{{- $sentinelEnabled := eq (toString ($sentinel.enabled | default false)) "true" -}} +{{- $tlsEnabled := eq (toString ($tls.enabled | default false)) "true" -}} +{{- $externalRedisActive := or $externalRedis.enabled $externalRedis.url $externalRedis.host $sentinelEnabled -}} +{{- if and .Values.redis.enabled $externalRedisActive -}} +{{- fail "redis.enabled and externalRedis are mutually exclusive (unset externalRedis enabled/url/host/sentinel)" -}} +{{- end -}} {{- if .Values.redis.enabled -}} {{- $env = append $env (dict "name" "REDIS_URL" "value" (include "trueforge.redis.bundledUrl" .)) -}} {{- else if $externalRedisActive -}} -{{- if and (not $sentinel.enabled) (not $externalRedis.url) (not $externalRedis.host) -}} +{{- if and (not $sentinelEnabled) (not $externalRedis.url) (not $externalRedis.host) -}} {{- fail "externalRedis.url or externalRedis.host is required when using external Redis without sentinel" -}} {{- end -}} +{{- if and (not $sentinelEnabled) $tlsEnabled (kindIs "string" $externalRedis.url) (hasPrefix "redis://" $externalRedis.url) -}} +{{- fail "externalRedis.tls.enabled requires a rediss:// url; a redis:// url with TLS fails at startup" -}} +{{- end -}} +{{- if and (not $sentinelEnabled) $externalRedis.url (or $auth.username $auth.password (ne (($externalRedis.db | default 0) | toString) "0")) -}} +{{- fail "externalRedis.auth and a non-zero externalRedis.db are ignored when externalRedis.url is set; encode them in the url or use host mode" -}} +{{- end -}} {{- if $externalRedis.url -}} {{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" $externalRedis.url) | fromJson) -}} {{- end -}} @@ -453,16 +463,14 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- 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 -}} +{{- $env = append $env (dict "name" "REDIS_TLS_ENABLED" "value" (ternary "true" "false" $tlsEnabled)) -}} +{{- if $tlsEnabled -}} {{- 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 -}} @@ -479,8 +487,8 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $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 -}} +{{- $env = append $env (dict "name" "REDIS_SENTINEL_ENABLED" "value" (ternary "true" "false" $sentinelEnabled)) -}} +{{- if $sentinelEnabled -}} {{- $_ := 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_NODES" "value" (join "," $sentinel.nodes)) -}} From 1fc771f20b6479f6bb4e5a14f2c66c4c8d759d6e Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 18:03:40 +0530 Subject: [PATCH 06/17] feat: enhance Redis Sentinel and TLS support with exclusive transport validation and improved error handling --- .changeset/redis-sentinel.md | 2 +- charts/trueforge/README.md | 10 +- charts/trueforge/templates/_helpers.tpl | 51 +++--- charts/trueforge/values.yaml | 8 +- packages/trueforge/.env.example | 15 +- packages/trueforge/src/config.ts | 157 ++++++++++------ packages/trueforge/src/main.ts | 49 ++--- packages/trueforge/src/runtime/redis.ts | 227 +++++++++++------------- 8 files changed, 280 insertions(+), 239 deletions(-) diff --git a/.changeset/redis-sentinel.md b/.changeset/redis-sentinel.md index 8e11b4bbd..323505c6c 100644 --- a/.changeset/redis-sentinel.md +++ b/.changeset/redis-sentinel.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-core': minor --- -Add Redis Sentinel + TLS support (`REDIS_*` env and Helm `externalRedis`). +Add Redis Sentinel + TLS with exclusive transport fail-fast (`REDIS_CONNECTION` DU). diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index b5157768a..ce8dcb525 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -213,10 +213,10 @@ 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` 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`: +**external** Redis, set `redis.enabled=false` and provide exactly one of +`externalRedis.url`, `externalRedis.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`. url, host, and sentinel are mutually exclusive: ```yaml redis: @@ -224,7 +224,7 @@ redis: externalRedis: enabled: true url: redis://:password@redis-master.databases.svc:6379 - # or host + auth when url is unset: + # or host + auth (not with url): # host: redis-master.databases.svc # port: 6379 # auth: diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index ff86b05c5..1c136e1ee 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -442,25 +442,50 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- if .Values.redis.enabled -}} {{- $env = append $env (dict "name" "REDIS_URL" "value" (include "trueforge.redis.bundledUrl" .)) -}} {{- else if $externalRedisActive -}} +{{- if and $sentinel.enabled (or $externalRedis.url $externalRedis.host) -}} +{{- fail "externalRedis.sentinel cannot be combined with externalRedis.url or externalRedis.host" -}} +{{- end -}} +{{- if and $externalRedis.url $externalRedis.host -}} +{{- fail "externalRedis.url and externalRedis.host are mutually exclusive" -}} +{{- end -}} {{- 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) -}} +{{- $auth := $externalRedis.auth | default dict -}} +{{- $tls := $externalRedis.tls | default dict -}} +{{- if $sentinel.enabled -}} +{{- $_ := 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_ENABLED" "value" "true") -}} +{{- $env = append $env (dict "name" "REDIS_SENTINEL_NODES" "value" (join "," $sentinel.nodes)) -}} +{{- $env = append $env (dict "name" "REDIS_SENTINEL_MASTER_NAME" "value" $sentinel.masterName) -}} +{{- $env = append $env (dict "name" "REDIS_DB" "value" (($externalRedis.db | default 0) | toString)) -}} +{{- if $auth.username -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_USERNAME" "field" "externalRedis.auth.username" "value" $auth.username) | fromJson) -}} {{- end -}} -{{- if $externalRedis.host -}} -{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" $externalRedis.host) | fromJson) -}} +{{- if $auth.password -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_PASSWORD" "field" "externalRedis.auth.password" "value" $auth.password) | fromJson) -}} +{{- end -}} +{{- $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 -}} +{{- else if $externalRedis.url -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" $externalRedis.url) | fromJson) -}} +{{- else -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" $externalRedis.host) | fromJson) -}} {{- $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 -}} +{{- end -}} {{- $env = append $env (dict "name" "REDIS_TLS_ENABLED" "value" (ternary "true" "false" (eq $tls.enabled true))) -}} {{- if $tls.enabled -}} {{- if $tls.caCert -}} @@ -479,20 +504,6 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $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.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_NODES" "value" (join "," $sentinel.nodes)) -}} -{{- $env = append $env (dict "name" "REDIS_SENTINEL_MASTER_NAME" "value" $sentinel.masterName) -}} -{{- $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 -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index 309d619c1..09a589b6a 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -273,16 +273,17 @@ externalPostgres: externalRedis: ## 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). + ## Exactly one transport: url, host, or sentinel (mutually exclusive). 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). + ## string or valueFrom. Mutually exclusive with host and sentinel. url: "" - ## Standalone Redis host (used when url is unset; optional when sentinel.enabled). + ## Standalone Redis host. Mutually exclusive with url and sentinel. host: "" port: 6379 db: 0 auth: - ## string or valueFrom + ## string or valueFrom (host/sentinel modes; put credentials in url when using url) username: "" password: "" tls: @@ -294,6 +295,7 @@ externalRedis: key: "" keyPassphrase: "" sentinel: + ## Mutually exclusive with url and host. Requires nodes + masterName when enabled. enabled: false ## Array of host:port entries nodes: [] diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 11427ab2d..390c46839 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -55,12 +55,11 @@ PORT=8790 ## Windows: %LOCALAPPDATA%\trueforge\Data\db\db.sqlite # SQLITE_PATH= -## Used when STANDALONE=false (ignored in standalone). Redis connection URL for -## executor peering. Preferred over REDIS_HOST when set (may include userinfo). -## Required unless REDIS_HOST or Sentinel is configured. +## Used when STANDALONE=false (ignored in standalone). Redis transports are +## mutually exclusive — set exactly one of REDIS_URL, REDIS_HOST, or Sentinel. ## `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): +# Or host-based (mutually exclusive with REDIS_URL / Sentinel): # REDIS_HOST=localhost # REDIS_PORT=6379 # REDIS_DB=0 @@ -76,14 +75,18 @@ REDIS_URL=redis://localhost:6379 # 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 (STANDALONE=false only). Mutually exclusive with REDIS_URL / REDIS_HOST. +## When enabled, nodes + master name are required; 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 client timeouts (STANDALONE=false only). Optional. +# REDIS_CONNECT_TIMEOUT_MS=20000 +# REDIS_PING_INTERVAL_MS=5000 + ## 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. # REDIS_REQUEST_REPLY_TIMEOUT_MS=60000 diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index a4a679440..e8dfa0471 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -8,7 +8,7 @@ * * `STANDALONE` is a discriminated mode selector: * - `true` (default): SQLite only; no Redis / executor peering. - * - `false`: Postgres + Redis (`REDIS_URL`, `REDIS_HOST`, or Sentinel required). + * - `false`: Postgres + Redis (exactly one of `REDIS_URL`, `REDIS_HOST`, or Sentinel). */ import { existsSync, readFileSync } from 'node:fs'; import os from 'node:os'; @@ -18,6 +18,10 @@ import { fileURLToPath } from 'node:url'; import envPaths from 'env-paths'; import { z } from 'zod'; +import { parseRedisSentinelNodes, type RedisConnection } from './runtime/redis'; + +export type { RedisConnection }; + const DEFAULT_PORT = 8790; /** Loopback default; container images set HOST=0.0.0.0 so probes and Service traffic reach the process. */ const DEFAULT_HOST = 'localhost'; @@ -359,13 +363,93 @@ function resolveCodeModeSocketParent(): string { return path.join(os.tmpdir(), 'tf_cms'); } -/** Redis peering URL for distributed mode. Env: `REDIS_URL`. Preferred over `REDIS_HOST` when set. */ -function resolveRedisUrl(): string | undefined { - const raw = getEnv('REDIS_URL'); +/** Non-empty trimmed env string, or undefined. */ +function optionalNonEmptyEnv(envKey: string): string | undefined { + const raw = getEnv(envKey); if (raw === undefined || raw.trim() === '') { return undefined; } - return raw; + return raw.trim(); +} + +/** + * Exactly one Redis transport for distributed mode. + * Transports are mutually exclusive: Sentinel, `REDIS_URL`, or `REDIS_HOST`. + */ +function resolveRedisConnection(): RedisConnection { + const sentinelEnabled = parseBoolean({ + envKey: 'REDIS_SENTINEL_ENABLED', + raw: getEnv('REDIS_SENTINEL_ENABLED'), + defaultValue: false, + }); + const url = optionalNonEmptyEnv('REDIS_URL'); + const host = optionalNonEmptyEnv('REDIS_HOST'); + const database = parseNonNegativeInt({ + envKey: 'REDIS_DB', + raw: getEnv('REDIS_DB'), + defaultValue: 0, + }); + const username = optionalNonEmptyEnv('REDIS_USERNAME'); + const password = getEnv('REDIS_PASSWORD'); + const passwordOrUndefined = password === undefined || password === '' ? undefined : password; + + if (sentinelEnabled) { + const nodes = optionalNonEmptyEnv('REDIS_SENTINEL_NODES'); + const masterName = optionalNonEmptyEnv('REDIS_SENTINEL_MASTER_NAME'); + if (!nodes || !masterName || parseRedisSentinelNodes(nodes).length === 0) { + throw new Error( + 'REDIS_SENTINEL_ENABLED=true requires non-empty REDIS_SENTINEL_NODES and REDIS_SENTINEL_MASTER_NAME.', + ); + } + if (url !== undefined || host !== undefined) { + throw new Error( + 'Redis transports are mutually exclusive: unset REDIS_URL and REDIS_HOST when REDIS_SENTINEL_ENABLED=true.', + ); + } + const sentinelUsername = optionalNonEmptyEnv('REDIS_SENTINEL_USERNAME'); + const sentinelPasswordRaw = getEnv('REDIS_SENTINEL_PASSWORD'); + const sentinelPassword = + sentinelPasswordRaw === undefined || sentinelPasswordRaw === '' ? undefined : sentinelPasswordRaw; + return { + mode: 'sentinel', + nodes, + masterName, + database, + ...(username !== undefined ? { username } : {}), + ...(passwordOrUndefined !== undefined ? { password: passwordOrUndefined } : {}), + ...(sentinelUsername !== undefined ? { sentinelUsername } : {}), + ...(sentinelPassword !== undefined ? { sentinelPassword } : {}), + }; + } + + if (url !== undefined && host !== undefined) { + throw new Error( + 'Redis transports are mutually exclusive: set only one of REDIS_URL or REDIS_HOST (or enable Sentinel).', + ); + } + if (url !== undefined) { + return { mode: 'url', url }; + } + if (host !== undefined) { + const port = parsePositiveInt({ + envKey: 'REDIS_PORT', + raw: getEnv('REDIS_PORT'), + defaultValue: 6379, + }); + return { + mode: 'host', + host, + port, + database, + ...(username !== undefined ? { username } : {}), + ...(passwordOrUndefined !== undefined ? { password: passwordOrUndefined } : {}), + }; + } + + throw new Error( + 'Set exactly one Redis transport when STANDALONE=false: REDIS_URL, REDIS_HOST, or ' + + 'REDIS_SENTINEL_ENABLED with REDIS_SENTINEL_NODES and REDIS_SENTINEL_MASTER_NAME.', + ); } /** @@ -749,33 +833,14 @@ export type DistributedServerConfiguration = SharedServerConfiguration & { */ POSTGRES_SCHEMA: 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`. + * Resolved Redis transport (exactly one of url / host / sentinel). + * Env: mutually exclusive `REDIS_URL`, `REDIS_HOST`, or `REDIS_SENTINEL_*`. */ - 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; + REDIS_CONNECTION: RedisConnection; + /** Socket connect timeout for Redis clients. Env: `REDIS_CONNECT_TIMEOUT_MS`. Default 20000. */ + REDIS_CONNECT_TIMEOUT_MS: number; + /** Client ping interval for Redis keepalive. Env: `REDIS_PING_INTERVAL_MS`. Default 5000. */ + REDIS_PING_INTERVAL_MS: number; /** 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`. */ @@ -1033,29 +1098,17 @@ const configuration: ServerConfiguration = standalone defaultValue: 60_000, }), POSTGRES_SCHEMA: parsePostgresSchema(getEnv('POSTGRES_SCHEMA')), - 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_CONNECTION: resolveRedisConnection(), + REDIS_CONNECT_TIMEOUT_MS: parsePositiveInt({ + envKey: 'REDIS_CONNECT_TIMEOUT_MS', + raw: getEnv('REDIS_CONNECT_TIMEOUT_MS'), + defaultValue: 20_000, }), - 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_PING_INTERVAL_MS: parsePositiveInt({ + envKey: 'REDIS_PING_INTERVAL_MS', + raw: getEnv('REDIS_PING_INTERVAL_MS'), + defaultValue: 5_000, }), - 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'), diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index 39512348d..257731fe6 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -94,7 +94,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 type { ConnectedRedis } from './runtime/redis'; import { printStandaloneStartupBanner } from './startupBanner'; import { InlineMcpServerStore } from './truefoundry/InlineMcpServerStore'; import { parseInlineMcpServers, parseInlineSkills, X_TFG_MCP, X_TFG_SKILLS } from './truefoundry/inlineResources'; @@ -136,7 +136,8 @@ interface ServerPersistence { agentStore: IAgentStore; turnSkillsResolverStore: Pick, 'resolveTurnSkills'>; destroyDb: () => Promise; - redis: RedisPeerClient | undefined; + /** Connected Redis (client + mode) for distributed peering; undefined in standalone. */ + redis: ConnectedRedis | undefined; /** One shared client for TrueFoundry store resolvers + auth; undefined when TrueFoundry mode is off. */ serviceFoundryClient: TrueFoundryServiceFoundryServerClient | undefined; } @@ -365,17 +366,9 @@ async function createDistributedPersistence(options: { DATABASE_POOL_MAX: databasePoolMax, 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_CONNECTION: redisConnection, + REDIS_CONNECT_TIMEOUT_MS: redisConnectTimeoutMs, + REDIS_PING_INTERVAL_MS: redisPingIntervalMs, REDIS_TLS_ENABLED: redisTlsEnabled, REDIS_TLS_CA_CERT: redisTlsCaCert, REDIS_TLS_REJECT_UNAUTHORIZED: redisTlsRejectUnauthorized, @@ -499,20 +492,10 @@ async function createDistributedPersistence(options: { turnSkillsResolverStore, destroyDb: () => db.destroy(), redis: await connectRedis({ - url: redisUrl, - host: redisHost, - port: redisPort, - database: redisDb, - username: redisUsername, - password: redisPassword, + connection: redisConnection, + connectTimeoutMs: redisConnectTimeoutMs, + pingIntervalMs: redisPingIntervalMs, logger, - sentinel: { - enabled: redisSentinelEnabled, - nodes: redisSentinelNodes, - masterName: redisSentinelMasterName, - username: redisSentinelUsername, - password: redisSentinelPassword, - }, tls: { enabled: redisTlsEnabled, caCert: redisTlsCaCert, @@ -546,7 +529,7 @@ async function createServerRuntime(persistence: ServerPersistence< const activeTurns = new ActiveTurnRegistry(); const requestReplyRouter = new RequestReplyRouter(); - const eventSubscriptions = new EventSubscriptionRegistry(redis); + const eventSubscriptions = new EventSubscriptionRegistry(redis?.client); const sessions = new Sessions({ sessionStore }); const oidc = isOidcConfigured(configuration) ? configuration.OIDC : undefined; @@ -642,7 +625,7 @@ async function createServerRuntime(persistence: ServerPersistence< sessionMetricsStore, sessions, activeTurns, - redis, + redis: redis?.client, requestReplyRouter, eventSubscriptions, logger, @@ -718,19 +701,19 @@ try { let requestReplySubscriberOwned = false; let requestReplyExecutor: RequestReplyExecutor | undefined; if (redis) { - if (isStandaloneRedisClient(redis)) { - requestReplySubscriber = redis.duplicate(); + if (redis.mode !== 'sentinel') { + requestReplySubscriber = redis.client.duplicate(); requestReplySubscriberOwned = true; requestReplySubscriber.on('error', (error: Error) => { logger.error('[RedisSubscriber] Client error', extractErrorLogFields(error)); }); await requestReplySubscriber.connect(); } else { - requestReplySubscriber = redis; + requestReplySubscriber = redis.client; } requestReplyExecutor = new RequestReplyExecutor({ executorId: configuration.EXECUTOR_ID, - redis, + redis: redis.client, subscriberClient: requestReplySubscriber, requestHandler: requestReplyRouter.createRequestHandler(), logger, @@ -809,7 +792,7 @@ try { logger.warn('[Redis] Error closing subscriber client during shutdown', extractErrorLogFields(error)); }); } - await redis?.close().catch((error: unknown) => { + await redis?.client.close().catch((error: unknown) => { logger.warn('[Redis] Error closing client during shutdown', extractErrorLogFields(error)); }); if (configuration.STANDALONE) { diff --git a/packages/trueforge/src/runtime/redis.ts b/packages/trueforge/src/runtime/redis.ts index 0deaeff62..dc78dc0f9 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -12,13 +12,36 @@ import { createClient, createSentinel, type RedisClientType } from 'redis'; import type { Logger } from 'winston'; 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; +/** Exactly one Redis transport, resolved at config load. */ +export type RedisConnection = + | { mode: 'url'; url: string } + | { + mode: 'host'; + host: string; + port: number; + database: number; + username?: string; + password?: string; + } + | { + mode: 'sentinel'; + nodes: string; + masterName: string; + database: number; + username?: string; + password?: string; + sentinelUsername?: string; + sentinelPassword?: string; + }; + +export type ConnectedRedis = + | { mode: 'url'; client: RedisClientType } + | { mode: 'host'; client: RedisClientType } + | { mode: 'sentinel'; client: RedisPeerClient }; + /** Parse comma-separated `host:port` list into Sentinel root nodes. */ export function parseRedisSentinelNodes(raw: string): { host: string; port: number }[] { return raw @@ -36,24 +59,6 @@ export function parseRedisSentinelNodes(raw: string): { host: string; port: numb }); } -/** 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; @@ -64,13 +69,9 @@ export interface RedisTlsInput { 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 { +function resolvePemMaterial({ value, label }: { value: string; label: string }): string { if (value.includes(PEM_MARKER)) { return value; } @@ -112,9 +113,9 @@ function buildTlsSocketOptions(tls: RedisTlsInput | undefined): 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.caCert ? { ca: resolvePemMaterial({ value: tls.caCert, label: 'REDIS_TLS_CA_CERT' }) } : {}), + ...(tls.cert ? { cert: resolvePemMaterial({ value: tls.cert, label: 'REDIS_TLS_CERT' }) } : {}), + ...(tls.key ? { key: resolvePemMaterial({ value: tls.key, label: 'REDIS_TLS_KEY' }) } : {}), ...(tls.keyPassphrase ? { passphrase: tls.keyPassphrase } : {}), ...(tls.serverName ? { servername: tls.serverName } : {}), }; @@ -134,14 +135,9 @@ function sleep(ms: number): Promise { * 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 }; + connection: Extract; clientDefaults: { disableOfflineQueue: true; pingInterval: number }; + connectTimeoutMs: number; socketTls: | { tls: true; @@ -155,20 +151,26 @@ async function connectSentinelWithRetry(input: { | undefined; logger: Logger; }): Promise> { + const { connection } = input; + const auth = { + database: connection.database, + ...(connection.username ? { username: connection.username } : {}), + ...(connection.password ? { password: connection.password } : {}), + }; let attempt = 0; for (;;) { const client = createSentinel({ - name: input.sentinel.masterName?.trim() ?? '', - sentinelRootNodes: parseRedisSentinelNodes(input.sentinel.nodes ?? ''), + name: connection.masterName, + sentinelRootNodes: parseRedisSentinelNodes(connection.nodes), nodeClientOptions: { ...input.clientDefaults, - ...input.auth, - socket: { connectTimeout: CONNECT_TIMEOUT_MS, ...(input.socketTls ?? {}) }, + ...auth, + socket: { connectTimeout: input.connectTimeoutMs, ...(input.socketTls ?? {}) }, }, sentinelClientOptions: { - socket: { connectTimeout: CONNECT_TIMEOUT_MS, ...(input.socketTls ?? {}) }, - ...(input.sentinel.username ? { username: input.sentinel.username } : {}), - ...(input.sentinel.password ? { password: input.sentinel.password } : {}), + socket: { connectTimeout: input.connectTimeoutMs, ...(input.socketTls ?? {}) }, + ...(connection.sentinelUsername ? { username: connection.sentinelUsername } : {}), + ...(connection.sentinelPassword ? { password: connection.sentinelPassword } : {}), }, }); // Without an 'error' listener node-redis crashes the process on emit. @@ -198,88 +200,75 @@ async function connectSentinelWithRetry(input: { } } -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; +export interface ConnectRedisInput { + connection: RedisConnection; tls: RedisTlsInput | undefined; -}): Promise { + logger: Logger; + connectTimeoutMs: number; + pingIntervalMs: number; +} + +export async function connectRedis(input: ConnectRedisInput): Promise { input.logger.info('Connecting to Redis'); 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, + pingInterval: input.pingIntervalMs, } 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).', - ); + switch (input.connection.mode) { + case 'sentinel': { + // @ts-expect-error TS2375 createSentinel return not assignable under EOPT + const client: RedisPeerClient = await connectSentinelWithRetry({ + connection: input.connection, + clientDefaults, + connectTimeoutMs: input.connectTimeoutMs, + socketTls, + logger: input.logger, + }); + input.logger.info('Connected to Redis'); + return { mode: 'sentinel', client }; + } + case 'url': { + const created = createClient({ + url: input.connection.url, + ...clientDefaults, + socket: { connectTimeout: input.connectTimeoutMs, ...(socketTls ?? {}) }, + }); + // Without an 'error' listener node-redis crashes the process on emit. + created.on('error', (error: Error) => { + input.logger.error('[Redis] Client error', extractErrorLogFields(error)); + }); + await created.connect(); + input.logger.info('Connected to Redis'); + // @ts-expect-error TS2375 createClient return not assignable under EOPT + const client: RedisClientType = created; + return { mode: 'url', client }; + } + case 'host': { + const { host, port, database, username, password } = input.connection; + const created = createClient({ + ...clientDefaults, + database, + ...(username ? { username } : {}), + ...(password ? { password } : {}), + socket: { + host, + port, + connectTimeout: input.connectTimeoutMs, + ...(socketTls ?? {}), + }, + }); + // Without an 'error' listener node-redis crashes the process on emit. + created.on('error', (error: Error) => { + input.logger.error('[Redis] Client error', extractErrorLogFields(error)); + }); + await created.connect(); + input.logger.info('Connected to Redis'); + // @ts-expect-error TS2375 createClient return not assignable under EOPT + const client: RedisClientType = created; + return { mode: 'host', client }; + } } - - input.logger.info('Connected to Redis'); - return client; } From b5b0b9d4ed12f645154bd0e3f20a5f0e65e30544 Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta <120925871+thesujai@users.noreply.github.com> Date: Tue, 22 Sep 2026 18:13:16 +0530 Subject: [PATCH 07/17] Update sessions.ts --- packages/trueforge/src/apis/sessions.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index 183bb96b5..b90d7d360 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -83,7 +83,6 @@ export interface SessionsRouterDeps { resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore; redis?: RedisPeerClient | undefined; resolveWebSearchProviderStore: (c: Context) => IWebSearchProviderStore; - redis?: RedisClientType | undefined; requestReplyRouter: RequestReplyRouter; resolveRequestContext: ResolveRequestContext; logger: Logger; From 3efb0525772a8d2a2c41bfc890f131ea91129086 Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 18:33:14 +0530 Subject: [PATCH 08/17] feat: refine Redis Sentinel and TLS configuration with optional Redis for controller/migrate --- .changeset/redis-sentinel.md | 2 +- packages/trueforge/.env.example | 5 +++-- packages/trueforge/src/config.ts | 18 +++++++++--------- packages/trueforge/src/main.ts | 6 ++++++ 4 files changed, 19 insertions(+), 12 deletions(-) diff --git a/.changeset/redis-sentinel.md b/.changeset/redis-sentinel.md index 323505c6c..72e00471b 100644 --- a/.changeset/redis-sentinel.md +++ b/.changeset/redis-sentinel.md @@ -3,4 +3,4 @@ '@truefoundry/trueforge-core': minor --- -Add Redis Sentinel + TLS with exclusive transport fail-fast (`REDIS_CONNECTION` DU). +Add Redis Sentinel + TLS with exclusive transport fail-fast (`REDIS_CONNECTION` DU). Redis is optional at config load for controller/migrate; server still requires it at connect. Sentinel shared-client errors no longer stop the peering heartbeat. diff --git a/packages/trueforge/.env.example b/packages/trueforge/.env.example index 7ae26d5c7..43161d6ab 100644 --- a/packages/trueforge/.env.example +++ b/packages/trueforge/.env.example @@ -58,8 +58,9 @@ PORT=8790 ## Windows: %LOCALAPPDATA%\trueforge\Data\db\db.sqlite # SQLITE_PATH= -## Used when STANDALONE=false (ignored in standalone). Redis transports are -## mutually exclusive — set exactly one of REDIS_URL, REDIS_HOST, or Sentinel. +## Used when STANDALONE=false for the server (ignored in standalone; optional for +## controller / migrate). Redis transports are mutually exclusive — set exactly +## one of REDIS_URL, REDIS_HOST, or Sentinel. ## `docker-compose.yml` overrides this to redis://redis:6379 for the server container. REDIS_URL=redis://localhost:6379 # Or host-based (mutually exclusive with REDIS_URL / Sentinel): diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 329bb8aca..0dd26e6b0 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -8,7 +8,7 @@ * * `STANDALONE` is a discriminated mode selector: * - `true` (default): SQLite only; no Redis / executor peering. - * - `false`: Postgres + Redis (exactly one of `REDIS_URL`, `REDIS_HOST`, or Sentinel). + * - `false`: Postgres; Redis required for the server (optional for controller / migrate). */ import { existsSync, readFileSync } from 'node:fs'; import os from 'node:os'; @@ -373,10 +373,12 @@ function optionalNonEmptyEnv(envKey: string): string | undefined { } /** - * Exactly one Redis transport for distributed mode. + * Exactly one Redis transport when configured. * Transports are mutually exclusive: Sentinel, `REDIS_URL`, or `REDIS_HOST`. + * Returns undefined when none are set so controller / migrate can boot without Redis; + * the server fails at connect time if still unset. */ -function resolveRedisConnection(): RedisConnection { +function resolveRedisConnection(): RedisConnection | undefined { const sentinelEnabled = parseBoolean({ envKey: 'REDIS_SENTINEL_ENABLED', raw: getEnv('REDIS_SENTINEL_ENABLED'), @@ -446,10 +448,7 @@ function resolveRedisConnection(): RedisConnection { }; } - throw new Error( - 'Set exactly one Redis transport when STANDALONE=false: REDIS_URL, REDIS_HOST, or ' + - 'REDIS_SENTINEL_ENABLED with REDIS_SENTINEL_NODES and REDIS_SENTINEL_MASTER_NAME.', - ); + return undefined; } /** @@ -839,10 +838,11 @@ export type DistributedServerConfiguration = SharedServerConfiguration & { */ POSTGRES_SCHEMA: string; /** - * Resolved Redis transport (exactly one of url / host / sentinel). + * Resolved Redis transport (exactly one of url / host / sentinel), or undefined. + * Required for the server process; optional for controller / migrate (no peering). * Env: mutually exclusive `REDIS_URL`, `REDIS_HOST`, or `REDIS_SENTINEL_*`. */ - REDIS_CONNECTION: RedisConnection; + REDIS_CONNECTION: RedisConnection | undefined; /** Socket connect timeout for Redis clients. Env: `REDIS_CONNECT_TIMEOUT_MS`. Default 20000. */ REDIS_CONNECT_TIMEOUT_MS: number; /** Client ping interval for Redis keepalive. Env: `REDIS_PING_INTERVAL_MS`. Default 5000. */ diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index ff9e1c613..e07996e66 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -437,6 +437,12 @@ async function createDistributedPersistence(options: { }); await migrateToLatest(db); logger.info(`Executor id: ${executorId}`); + if (redisConnection === undefined) { + throw new Error( + 'Set exactly one Redis transport for the server when STANDALONE=false: REDIS_URL, REDIS_HOST, or ' + + 'REDIS_SENTINEL_ENABLED with REDIS_SENTINEL_NODES and REDIS_SENTINEL_MASTER_NAME.', + ); + } const serviceFoundryClient = createServiceFoundryServerClient(logger); const tokenStore = new PostgresOAuthTokenStore(db); const modelProviderStore = new PostgresModelProviderStore(db); From 55439034d0af29a0e94977d44ac716e493902740 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Tue, 22 Sep 2026 18:32:29 +0530 Subject: [PATCH 09/17] chart: bump to 0.2.3, accept valueFrom for tls.serverName - pass tls.serverName through fromStringOrValueFrom like the other TLS fields so a valueFrom ref renders correctly - bump chart version: 0.2.2 is already pinned by the umbrella chart Signed-off-by: Raman Tehlan --- charts/trueforge/Chart.yaml | 2 +- charts/trueforge/templates/_helpers.tpl | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/charts/trueforge/Chart.yaml b/charts/trueforge/Chart.yaml index d1abde1bf..7150ae069 100644 --- a/charts/trueforge/Chart.yaml +++ b/charts/trueforge/Chart.yaml @@ -4,7 +4,7 @@ description: TrueForge server (API + UI) served from a single container image. type: application # version / appVersion are maintained on main (bot chart-release PR or human). # Publishing is gated by git tag charts/trueforge@ (must match version). -version: "0.2.2" +version: "0.2.3" appVersion: "0.2.1" kubeVersion: ">=1.25.0-0" home: https://truefoundry.com diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index b0737398f..fb34b75dc 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -500,7 +500,7 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $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) -}} +{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_SERVERNAME" "field" "externalRedis.tls.serverName" "value" $tls.serverName) | fromJson) -}} {{- end -}} {{- if $tls.cert -}} {{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_CERT" "field" "externalRedis.tls.cert" "value" $tls.cert) | fromJson) -}} From 5984b792fefa8d740dc2e086790825d9a7e5bb02 Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 19:06:31 +0530 Subject: [PATCH 10/17] fix: prevent stopping heartbeat on transient errors in RequestReplyExecutor --- packages/trueforge-core/src/request-reply/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/trueforge-core/src/request-reply/executor.ts b/packages/trueforge-core/src/request-reply/executor.ts index 6a5564bdf..eef621d17 100644 --- a/packages/trueforge-core/src/request-reply/executor.ts +++ b/packages/trueforge-core/src/request-reply/executor.ts @@ -108,7 +108,7 @@ export class RequestReplyExecutor { ...extractErrorLogFields(err), }); this.onError?.(err, { executorId: this.executorId, channel: this.channel }); - this.stopHeartbeat(); + // error is often transient; the client can reconnect and fire ready again. Do not stop the heartbeat. }); this.subscriberClient.on('end', () => { this.logger.warn('[RequestReplyExecutor] Subscriber connection ended', { executorId: this.executorId }); From dd8b6c0139ce3cc8de158ce35c6ec39d6f7928d9 Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 19:40:27 +0530 Subject: [PATCH 11/17] Address Chirag review on Redis Sentinel/TLS PR. Collapse host/url into standalone URL mode, rename RedisPeerClient to RedisClient, require externalRedis.enabled in Helm, and tidy TLS typing. Co-authored-by: Cursor --- charts/trueforge/README.md | 7 +- charts/trueforge/templates/_helpers.tpl | 9 +- charts/trueforge/values.yaml | 3 +- .../src/request-reply/client.ts | 6 +- .../src/request-reply/executor.ts | 10 +-- .../trueforge-core/src/request-reply/index.ts | 2 +- .../src/request-reply/redisClient.ts | 2 +- packages/trueforge/src/apis/sessions.ts | 6 +- packages/trueforge/src/app.ts | 4 +- packages/trueforge/src/config.ts | 42 +++++++-- packages/trueforge/src/main.ts | 8 +- .../src/runtime/event-subscription/index.ts | 4 +- .../src/runtime/event-subscription/redis.ts | 4 +- packages/trueforge/src/runtime/redis.ts | 89 +++++-------------- 14 files changed, 85 insertions(+), 111 deletions(-) diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 520367bb8..11aca8a91 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -213,10 +213,9 @@ 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 exactly one of -`externalRedis.url`, `externalRedis.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`. url, host, and sentinel are mutually exclusive: +**external** Redis, set `redis.enabled=false`, `externalRedis.enabled=true`, and exactly one +of `externalRedis.url`, `externalRedis.host`, or Sentinel. Fields accept a string or +`valueFrom.secretKeyRef`. url, host, and sentinel are mutually exclusive: ```yaml redis: diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index fb34b75dc..9308a1e2e 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -439,13 +439,12 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- $tls := $externalRedis.tls | default dict -}} {{- $sentinelEnabled := eq (toString ($sentinel.enabled | default false)) "true" -}} {{- $tlsEnabled := eq (toString ($tls.enabled | default false)) "true" -}} -{{- $externalRedisActive := or $externalRedis.enabled $externalRedis.url $externalRedis.host $sentinelEnabled -}} -{{- if and .Values.redis.enabled $externalRedisActive -}} -{{- fail "redis.enabled and externalRedis are mutually exclusive (unset externalRedis enabled/url/host/sentinel)" -}} +{{- if and .Values.redis.enabled $externalRedis.enabled -}} +{{- fail "redis.enabled and externalRedis.enabled are mutually exclusive" -}} {{- end -}} {{- if .Values.redis.enabled -}} {{- $env = append $env (dict "name" "REDIS_URL" "value" (include "trueforge.redis.bundledUrl" .)) -}} -{{- else if $externalRedisActive -}} +{{- else if $externalRedis.enabled -}} {{- if and $sentinelEnabled (or $externalRedis.url $externalRedis.host) -}} {{- fail "externalRedis.sentinel cannot be combined with externalRedis.url or externalRedis.host" -}} {{- end -}} @@ -513,7 +512,7 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- end -}} {{- else -}} -{{- fail "set redis.enabled or externalRedis (enabled, url, host, or sentinel)" -}} +{{- fail "set redis.enabled or externalRedis.enabled" -}} {{- end -}} {{- if .Values.postgresql.enabled -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index 09a589b6a..cc4fd7275 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -272,8 +272,7 @@ externalPostgres: sslCaPath: "" externalRedis: ## 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). - ## Exactly one transport: url, host, or sentinel (mutually exclusive). + ## Requires `enabled: true`, then exactly one transport: url, host, or sentinel. enabled: false ## Full connection URL, e.g. redis://:password@redis-master.databases.svc:6379. ## string or valueFrom. Mutually exclusive with host and sentinel. diff --git a/packages/trueforge-core/src/request-reply/client.ts b/packages/trueforge-core/src/request-reply/client.ts index f166af3c0..b580928bf 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 { NoResponderError, RequestTimeoutError } from './errors'; -import type { RedisPeerClient } from './redisClient'; +import type { RedisClient } 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: RedisPeerClient, rKey: string): Promise { +async function getDelReply(redisClient: RedisClient, 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: RedisPeerClient; + redis: RedisClient; 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 eef621d17..b1936e8e9 100644 --- a/packages/trueforge-core/src/request-reply/executor.ts +++ b/packages/trueforge-core/src/request-reply/executor.ts @@ -3,7 +3,7 @@ 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 { RedisClient } 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: RedisPeerClient; - private readonly subscriberClient: RedisPeerClient; + private readonly redis: RedisClient; + private readonly subscriberClient: RedisClient; 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: RedisPeerClient; + redis: RedisClient; /** Connected client to SUBSCRIBE on (duplicate or Sentinel). Caller owns its lifecycle. */ - subscriberClient: RedisPeerClient; + subscriberClient: RedisClient; 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 185cf1573..bf5cfd5cf 100644 --- a/packages/trueforge-core/src/request-reply/index.ts +++ b/packages/trueforge-core/src/request-reply/index.ts @@ -10,7 +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 type { RedisClient } 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 index 71a7cf8f3..26fcbbee5 100644 --- a/packages/trueforge-core/src/request-reply/redisClient.ts +++ b/packages/trueforge-core/src/request-reply/redisClient.ts @@ -1,4 +1,4 @@ import type { RedisClientType, RedisSentinelType } from 'redis'; /** Standalone or Sentinel client for request-reply / command use. */ -export type RedisPeerClient = RedisClientType | RedisSentinelType; +export type RedisClient = RedisClientType | RedisSentinelType; diff --git a/packages/trueforge/src/apis/sessions.ts b/packages/trueforge/src/apis/sessions.ts index b90d7d360..cbdf0a944 100644 --- a/packages/trueforge/src/apis/sessions.ts +++ b/packages/trueforge/src/apis/sessions.ts @@ -14,7 +14,7 @@ import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; import { redisRequest, RequestTimeoutError, - type RedisPeerClient, + type RedisClient, type RouteHandler as RequestReplyRouteHandler, type RequestReplyRouter, } from '@truefoundry/trueforge-core/request-reply'; @@ -81,7 +81,7 @@ export interface SessionsRouterDeps { resolveSkillStore: ResolveSkillStore; resolveAgentStore: (c: Context) => IAgentStore; resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore; - redis?: RedisPeerClient | undefined; + redis?: RedisClient | undefined; resolveWebSearchProviderStore: (c: Context) => IWebSearchProviderStore; requestReplyRouter: RequestReplyRouter; resolveRequestContext: ResolveRequestContext; @@ -128,7 +128,7 @@ export interface CancelTurnDeps { activeTurns: ActiveTurnRegistry; session: Pick; sessionStore: Pick; - redis?: RedisPeerClient | undefined; + redis?: RedisClient | undefined; logger: Pick; } diff --git a/packages/trueforge/src/app.ts b/packages/trueforge/src/app.ts index d855c8229..d893e2f46 100644 --- a/packages/trueforge/src/app.ts +++ b/packages/trueforge/src/app.ts @@ -3,7 +3,7 @@ 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 { RedisPeerClient, RequestReplyRouter } from '@truefoundry/trueforge-core/request-reply'; +import type { RedisClient, 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'; @@ -210,7 +210,7 @@ export interface ServerDeps { sessions: Sessions; activeTurns: ActiveTurnRegistry; /** Primary Redis client (server-owned); undefined in standalone mode. */ - redis?: RedisPeerClient | undefined; + redis?: RedisClient | 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 0dd26e6b0..15f4be87e 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -372,6 +372,32 @@ function optionalNonEmptyEnv(envKey: string): string | undefined { return raw.trim(); } +/** + * Build a standalone `redis://` URL from discrete host fields. + * TLS stays on socket options (`REDIS_TLS_*`), not the URL scheme — CA/cert/key + * cannot be expressed in `rediss://`, and mixing scheme + socket TLS is ambiguous. + */ +function buildRedisStandaloneUrl(parts: { + host: string; + port: number; + database: number; + username: string | undefined; + password: string | undefined; +}): string { + let auth = ''; + if (parts.username !== undefined) { + auth = `${encodeURIComponent(parts.username)}`; + if (parts.password !== undefined) { + auth += `:${encodeURIComponent(parts.password)}`; + } + auth += '@'; + } else if (parts.password !== undefined) { + auth = `:${encodeURIComponent(parts.password)}@`; + } + const dbPath = parts.database > 0 ? `/${String(parts.database)}` : ''; + return `redis://${auth}${parts.host}:${String(parts.port)}${dbPath}`; +} + /** * Exactly one Redis transport when configured. * Transports are mutually exclusive: Sentinel, `REDIS_URL`, or `REDIS_HOST`. @@ -430,7 +456,7 @@ function resolveRedisConnection(): RedisConnection | undefined { ); } if (url !== undefined) { - return { mode: 'url', url }; + return { mode: 'standalone', url }; } if (host !== undefined) { const port = parsePositiveInt({ @@ -439,12 +465,14 @@ function resolveRedisConnection(): RedisConnection | undefined { defaultValue: 6379, }); return { - mode: 'host', - host, - port, - database, - ...(username !== undefined ? { username } : {}), - ...(passwordOrUndefined !== undefined ? { password: passwordOrUndefined } : {}), + mode: 'standalone', + url: buildRedisStandaloneUrl({ + host, + port, + database, + username, + password: passwordOrUndefined, + }), }; } diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index e07996e66..a60435a1e 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -56,11 +56,7 @@ import { type ISessionStore, type TurnStreamingEvent, } from '@truefoundry/trueforge-core/agent-session'; -import { - RequestReplyExecutor, - RequestReplyRouter, - type RedisPeerClient, -} from '@truefoundry/trueforge-core/request-reply'; +import { RequestReplyExecutor, RequestReplyRouter, type RedisClient } from '@truefoundry/trueforge-core/request-reply'; import type { Kysely, Transaction } from 'kysely'; import type { Logger } from 'winston'; @@ -732,7 +728,7 @@ try { // 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 requestReplySubscriber: RedisClient | undefined; let requestReplySubscriberOwned = false; let requestReplyExecutor: RequestReplyExecutor | undefined; if (redis) { diff --git a/packages/trueforge/src/runtime/event-subscription/index.ts b/packages/trueforge/src/runtime/event-subscription/index.ts index 4dccb9cce..1fe701545 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 { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; +import type { RedisClient } 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: RedisPeerClient | undefined) {} + constructor(private readonly redis: RedisClient | 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 b367c81f3..f0ee5e8fb 100644 --- a/packages/trueforge/src/runtime/event-subscription/redis.ts +++ b/packages/trueforge/src/runtime/event-subscription/redis.ts @@ -1,4 +1,4 @@ -import type { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; +import type { RedisClient } from '@truefoundry/trueforge-core/request-reply'; import { setTimeout as sleep } from 'node:timers/promises'; import { StreamGoneError, @@ -31,7 +31,7 @@ export class RedisEventSubscription implements EventSubscripti private nextSequenceNumber = 1; constructor( - private readonly redis: RedisPeerClient, + private readonly redis: RedisClient, private readonly streamId: string, ) {} diff --git a/packages/trueforge/src/runtime/redis.ts b/packages/trueforge/src/runtime/redis.ts index dc78dc0f9..4725ebfc6 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -5,10 +5,10 @@ * Sentinel shares the same client for pub/sub). */ import { existsSync, readFileSync } from 'node:fs'; +import { setTimeout as sleep } from 'node:timers/promises'; import { extractErrorLogFields } from '@truefoundry/trueforge-core/core'; -import type { RedisPeerClient } from '@truefoundry/trueforge-core/request-reply'; -import { createClient, createSentinel, type RedisClientType } from 'redis'; +import { createClient, createSentinel, type RedisClientType, type RedisSentinelType } from 'redis'; import type { Logger } from 'winston'; const DEFAULT_SENTINEL_PORT = 26379; @@ -17,15 +17,7 @@ const SENTINEL_RETRY_MAX_MS = 3_000; /** Exactly one Redis transport, resolved at config load. */ export type RedisConnection = - | { mode: 'url'; url: string } - | { - mode: 'host'; - host: string; - port: number; - database: number; - username?: string; - password?: string; - } + | { mode: 'standalone'; url: string } | { mode: 'sentinel'; nodes: string; @@ -38,9 +30,7 @@ export type RedisConnection = }; export type ConnectedRedis = - | { mode: 'url'; client: RedisClientType } - | { mode: 'host'; client: RedisClientType } - | { mode: 'sentinel'; client: RedisPeerClient }; + { mode: 'standalone'; client: RedisClientType } | { mode: 'sentinel'; client: RedisSentinelType }; /** Parse comma-separated `host:port` list into Sentinel root nodes. */ export function parseRedisSentinelNodes(raw: string): { host: string; port: number }[] { @@ -69,6 +59,17 @@ export interface RedisTlsInput { keyPassphrase: string | undefined; } +/** node-redis socket TLS fields shared by data-node and Sentinel clients. */ +export type RedisTlsSocketOptions = { + tls: true; + rejectUnauthorized: boolean; + ca?: string; + cert?: string; + key?: string; + passphrase?: string; + servername?: string; +}; + const PEM_MARKER = '-----BEGIN'; function resolvePemMaterial({ value, label }: { value: string; label: string }): string { @@ -89,23 +90,13 @@ function resolvePemMaterial({ value, label }: { value: string; label: string }): } /** 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 { +function buildTlsSocketOptions(tls: RedisTlsInput | undefined): RedisTlsSocketOptions | 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 ' + + '[Redis] TLS misconfigured: REDIS_TLS_CERT and REDIS_TLS_KEY must be set together ' + '(provide both for mutual TLS, or neither).', ); } @@ -121,10 +112,6 @@ function buildTlsSocketOptions(tls: RedisTlsInput | undefined): }; } -function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); -} - /** * Connect via Redis Sentinel with rebuild-and-retry. * @@ -138,17 +125,7 @@ async function connectSentinelWithRetry(input: { connection: Extract; clientDefaults: { disableOfflineQueue: true; pingInterval: number }; connectTimeoutMs: number; - socketTls: - | { - tls: true; - rejectUnauthorized: boolean; - ca?: string; - cert?: string; - key?: string; - passphrase?: string; - servername?: string; - } - | undefined; + socketTls: RedisTlsSocketOptions | undefined; logger: Logger; }): Promise> { const { connection } = input; @@ -220,7 +197,7 @@ export async function connectRedis(input: ConnectRedisInput): Promise { - input.logger.error('[Redis] Client error', extractErrorLogFields(error)); - }); - await created.connect(); - input.logger.info('Connected to Redis'); - // @ts-expect-error TS2375 createClient return not assignable under EOPT - const client: RedisClientType = created; - return { mode: 'host', client }; + return { mode: 'standalone', client }; } } } From bec846a90eb5aff05841f405d68abcf6dac9164e Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 19:44:36 +0530 Subject: [PATCH 12/17] refactor: update Redis URL encoding and change type definition for Redis TLS options --- packages/trueforge/src/config.ts | 2 +- packages/trueforge/src/runtime/redis.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 15f4be87e..2a0db19ba 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -386,7 +386,7 @@ function buildRedisStandaloneUrl(parts: { }): string { let auth = ''; if (parts.username !== undefined) { - auth = `${encodeURIComponent(parts.username)}`; + auth = encodeURIComponent(parts.username); if (parts.password !== undefined) { auth += `:${encodeURIComponent(parts.password)}`; } diff --git a/packages/trueforge/src/runtime/redis.ts b/packages/trueforge/src/runtime/redis.ts index 4725ebfc6..9563ba04b 100644 --- a/packages/trueforge/src/runtime/redis.ts +++ b/packages/trueforge/src/runtime/redis.ts @@ -60,7 +60,7 @@ export interface RedisTlsInput { } /** node-redis socket TLS fields shared by data-node and Sentinel clients. */ -export type RedisTlsSocketOptions = { +export interface RedisTlsSocketOptions { tls: true; rejectUnauthorized: boolean; ca?: string; @@ -68,7 +68,7 @@ export type RedisTlsSocketOptions = { key?: string; passphrase?: string; servername?: string; -}; +} const PEM_MARKER = '-----BEGIN'; From a3710bd9a0cee2a668a2b09151d193f7360e7753 Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 19:53:03 +0530 Subject: [PATCH 13/17] Bracket IPv6 hosts when building Redis standalone URLs. Bare addresses like ::1 produced invalid redis:// URLs after host mode was collapsed to a URL. Co-authored-by: Cursor --- packages/trueforge/src/config.ts | 12 +++++-- .../tests/unit/config/config.test.ts | 32 ++++++++++++++++++- 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index 2a0db19ba..fb81c8e54 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -11,6 +11,7 @@ * - `false`: Postgres; Redis required for the server (optional for controller / migrate). */ import { existsSync, readFileSync } from 'node:fs'; +import { isIPv6 } from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -376,8 +377,9 @@ function optionalNonEmptyEnv(envKey: string): string | undefined { * Build a standalone `redis://` URL from discrete host fields. * TLS stays on socket options (`REDIS_TLS_*`), not the URL scheme — CA/cert/key * cannot be expressed in `rediss://`, and mixing scheme + socket TLS is ambiguous. + * IPv6 hosts are bracketed (`[::1]`) so `new URL()` / node-redis accept them. */ -function buildRedisStandaloneUrl(parts: { +export function buildRedisStandaloneUrl(parts: { host: string; port: number; database: number; @@ -394,8 +396,14 @@ function buildRedisStandaloneUrl(parts: { } else if (parts.password !== undefined) { auth = `:${encodeURIComponent(parts.password)}@`; } + const host = + parts.host.startsWith('[') && parts.host.endsWith(']') + ? parts.host + : isIPv6(parts.host) + ? `[${parts.host}]` + : parts.host; const dbPath = parts.database > 0 ? `/${String(parts.database)}` : ''; - return `redis://${auth}${parts.host}:${String(parts.port)}${dbPath}`; + return `redis://${auth}${host}:${String(parts.port)}${dbPath}`; } /** diff --git a/packages/trueforge/tests/unit/config/config.test.ts b/packages/trueforge/tests/unit/config/config.test.ts index 9b0dbe016..d13050dcb 100644 --- a/packages/trueforge/tests/unit/config/config.test.ts +++ b/packages/trueforge/tests/unit/config/config.test.ts @@ -1,4 +1,5 @@ -import { getPublicUiBasePath, type ServerConfiguration } from '../../../src/config'; +import type { ServerConfiguration } from '../../../src/config'; +import { buildRedisStandaloneUrl, getPublicUiBasePath } from '../../../src/config'; import { resolveTrueFoundrySandboxProviderConfig } from '../../../src/truefoundry/resolveTrueFoundrySandboxProviderConfig'; /** Minimal distributed config slice for resolve tests (unused fields are irrelevant). */ @@ -129,3 +130,32 @@ describe('getPublicUiBasePath', () => { ).toBe('/custom/proxy/path/'); }); }); + +describe('buildRedisStandaloneUrl', () => { + const base = { + port: 6379, + database: 0, + username: undefined, + password: undefined, + }; + + it('builds a hostname URL', () => { + expect(buildRedisStandaloneUrl({ ...base, host: 'redis.internal' })).toBe('redis://redis.internal:6379'); + }); + + it('brackets bare IPv6 hosts so the URL is parseable', () => { + const url = buildRedisStandaloneUrl({ ...base, host: '::1' }); + expect(url).toBe('redis://[::1]:6379'); + expect(() => new URL(url)).not.toThrow(); + }); + + it('keeps already-bracketed IPv6 hosts', () => { + expect(buildRedisStandaloneUrl({ ...base, host: '[2001:db8::1]', port: 6380, database: 2 })).toBe( + 'redis://[2001:db8::1]:6380/2', + ); + }); + + it('leaves IPv4 hosts unbracketed', () => { + expect(buildRedisStandaloneUrl({ ...base, host: '127.0.0.1' })).toBe('redis://127.0.0.1:6379'); + }); +}); From 2cffeeead6a392030371468255a9aa6fdbe9d680 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Tue, 22 Sep 2026 20:08:12 +0530 Subject: [PATCH 14/17] chart: drop the obsolete rediss:// requirement for TLS TLS is applied through node-redis socket options (REDIS_TLS_*), not the url scheme, and host mode builds a redis:// url itself. Failing on redis:// + tls.enabled rejected a working config and contradicted the host path, which allows the same combination. Signed-off-by: Raman Tehlan --- charts/trueforge/templates/_helpers.tpl | 3 --- 1 file changed, 3 deletions(-) diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 9308a1e2e..64e3cde4a 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -454,9 +454,6 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- if and (not $sentinelEnabled) (not $externalRedis.url) (not $externalRedis.host) -}} {{- fail "externalRedis.url or externalRedis.host is required when using external Redis without sentinel" -}} {{- end -}} -{{- if and (not $sentinelEnabled) $tlsEnabled (kindIs "string" $externalRedis.url) (hasPrefix "redis://" $externalRedis.url) -}} -{{- fail "externalRedis.tls.enabled requires a rediss:// url; a redis:// url with TLS fails at startup" -}} -{{- end -}} {{- if and (not $sentinelEnabled) $externalRedis.url (or $auth.username $auth.password (ne (($externalRedis.db | default 0) | toString) "0")) -}} {{- fail "externalRedis.auth and a non-zero externalRedis.db are ignored when externalRedis.url is set; encode them in the url or use host mode" -}} {{- end -}} From 6d6143ba4faf0ecd42fa5888ecea266d14fbc1b9 Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 20:10:32 +0530 Subject: [PATCH 15/17] feat: enhance buildRedisStandaloneUrl to support IPv6 host formatting and add unit tests --- packages/trueforge/src/config.ts | 34 ++++++++----------- packages/trueforge/src/main.ts | 21 ++++++------ .../tests/unit/config/config.test.ts | 6 ++-- 3 files changed, 28 insertions(+), 33 deletions(-) diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index fb81c8e54..eeac369f4 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -364,20 +364,21 @@ function resolveCodeModeSocketParent(): string { return path.join(os.tmpdir(), 'tf_cms'); } -/** Non-empty trimmed env string, or undefined. */ +/** Env string as-is, or undefined when unset/empty (no trimming). */ function optionalNonEmptyEnv(envKey: string): string | undefined { const raw = getEnv(envKey); - if (raw === undefined || raw.trim() === '') { + if (raw === undefined || raw === '') { return undefined; } - return raw.trim(); + return raw; } /** * Build a standalone `redis://` URL from discrete host fields. * TLS stays on socket options (`REDIS_TLS_*`), not the URL scheme — CA/cert/key * cannot be expressed in `rediss://`, and mixing scheme + socket TLS is ambiguous. - * IPv6 hosts are bracketed (`[::1]`) so `new URL()` / node-redis accept them. + * IPv6 hosts are bracketed so `new URL()` accepts them; username/password are + * percent-encoded by the URL setters. */ export function buildRedisStandaloneUrl(parts: { host: string; @@ -386,24 +387,17 @@ export function buildRedisStandaloneUrl(parts: { username: string | undefined; password: string | undefined; }): string { - let auth = ''; + const bareHost = + parts.host.startsWith('[') && parts.host.endsWith(']') ? parts.host.slice(1, -1) : parts.host; + const host = isIPv6(bareHost) ? `[${bareHost}]` : bareHost; + const url = new URL(`redis://${host}:${String(parts.port)}/${String(parts.database)}`); if (parts.username !== undefined) { - auth = encodeURIComponent(parts.username); - if (parts.password !== undefined) { - auth += `:${encodeURIComponent(parts.password)}`; - } - auth += '@'; - } else if (parts.password !== undefined) { - auth = `:${encodeURIComponent(parts.password)}@`; + url.username = parts.username; + } + if (parts.password !== undefined) { + url.password = parts.password; } - const host = - parts.host.startsWith('[') && parts.host.endsWith(']') - ? parts.host - : isIPv6(parts.host) - ? `[${parts.host}]` - : parts.host; - const dbPath = parts.database > 0 ? `/${String(parts.database)}` : ''; - return `redis://${auth}${host}:${String(parts.port)}${dbPath}`; + return url.href; } /** diff --git a/packages/trueforge/src/main.ts b/packages/trueforge/src/main.ts index a60435a1e..50a85f796 100644 --- a/packages/trueforge/src/main.ts +++ b/packages/trueforge/src/main.ts @@ -729,12 +729,10 @@ try { // awaits the initial subscribe + heartbeat — the replica is reachable for // peering before the HTTP server starts. let requestReplySubscriber: RedisClient | undefined; - let requestReplySubscriberOwned = false; let requestReplyExecutor: RequestReplyExecutor | undefined; if (redis) { if (redis.mode !== 'sentinel') { requestReplySubscriber = redis.client.duplicate(); - requestReplySubscriberOwned = true; requestReplySubscriber.on('error', (error: Error) => { logger.error('[RedisSubscriber] Client error', extractErrorLogFields(error)); }); @@ -815,17 +813,20 @@ try { await activeTurns.shutdownAndWait(CancellationReason.Abandoned); await closed; // Stop serving peer requests (waits for in-flight replies), then close - // clients this process owns: the subscriber duplicate (standalone only) - // and the primary. + // Redis clients (Set dedupes when Sentinel shares one client for pub/sub). await requestReplyExecutor?.drain(); - if (requestReplySubscriberOwned) { - await requestReplySubscriber?.close().catch((error: unknown) => { - logger.warn('[Redis] Error closing subscriber client during shutdown', extractErrorLogFields(error)); + const redisClients = new Set(); + if (requestReplySubscriber !== undefined) { + redisClients.add(requestReplySubscriber); + } + if (redis !== undefined) { + redisClients.add(redis.client); + } + for (const client of redisClients) { + await client.close().catch((error: unknown) => { + logger.warn('[Redis] Error closing client during shutdown', extractErrorLogFields(error)); }); } - await redis?.client.close().catch((error: unknown) => { - logger.warn('[Redis] Error closing client during shutdown', extractErrorLogFields(error)); - }); if (configuration.STANDALONE) { await removeCodeModeSocketParent(configuration.CODE_MODE_SOCKET_PARENT).catch((error: unknown) => { logger.warn('Error removing Code Mode socket parent during shutdown', extractErrorLogFields(error)); diff --git a/packages/trueforge/tests/unit/config/config.test.ts b/packages/trueforge/tests/unit/config/config.test.ts index d13050dcb..deb701b62 100644 --- a/packages/trueforge/tests/unit/config/config.test.ts +++ b/packages/trueforge/tests/unit/config/config.test.ts @@ -140,12 +140,12 @@ describe('buildRedisStandaloneUrl', () => { }; it('builds a hostname URL', () => { - expect(buildRedisStandaloneUrl({ ...base, host: 'redis.internal' })).toBe('redis://redis.internal:6379'); + expect(buildRedisStandaloneUrl({ ...base, host: 'redis.internal' })).toBe('redis://redis.internal:6379/0'); }); it('brackets bare IPv6 hosts so the URL is parseable', () => { const url = buildRedisStandaloneUrl({ ...base, host: '::1' }); - expect(url).toBe('redis://[::1]:6379'); + expect(url).toBe('redis://[::1]:6379/0'); expect(() => new URL(url)).not.toThrow(); }); @@ -156,6 +156,6 @@ describe('buildRedisStandaloneUrl', () => { }); it('leaves IPv4 hosts unbracketed', () => { - expect(buildRedisStandaloneUrl({ ...base, host: '127.0.0.1' })).toBe('redis://127.0.0.1:6379'); + expect(buildRedisStandaloneUrl({ ...base, host: '127.0.0.1' })).toBe('redis://127.0.0.1:6379/0'); }); }); From 35d7020a7ea9ef94f48f96711fb502312e09c57a Mon Sep 17 00:00:00 2001 From: thesujai Date: Tue, 22 Sep 2026 20:13:34 +0530 Subject: [PATCH 16/17] fix: remove unnecessary line breaks in buildRedisStandaloneUrl function for improved readability --- packages/trueforge/src/config.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/trueforge/src/config.ts b/packages/trueforge/src/config.ts index eeac369f4..6c89a6fea 100644 --- a/packages/trueforge/src/config.ts +++ b/packages/trueforge/src/config.ts @@ -387,8 +387,7 @@ export function buildRedisStandaloneUrl(parts: { username: string | undefined; password: string | undefined; }): string { - const bareHost = - parts.host.startsWith('[') && parts.host.endsWith(']') ? parts.host.slice(1, -1) : parts.host; + const bareHost = parts.host.startsWith('[') && parts.host.endsWith(']') ? parts.host.slice(1, -1) : parts.host; const host = isIPv6(bareHost) ? `[${bareHost}]` : bareHost; const url = new URL(`redis://${host}:${String(parts.port)}/${String(parts.database)}`); if (parts.username !== undefined) { From 4b04f86a644d0eae9843f6709ecccb0f845f60f7 Mon Sep 17 00:00:00 2001 From: Raman Tehlan Date: Tue, 22 Sep 2026 20:15:42 +0530 Subject: [PATCH 17/17] chart: expose externalRedis.tls.rejectUnauthorized The app reads REDIS_TLS_REJECT_UNAUTHORIZED (default true) but the chart never emitted it, so a Redis with a self-signed cert and no obtainable CA could only be reached through server.extraEnv. Key presence is tested rather than piping through default, which would turn an explicit false back into true. Also records the tls/sentinel fields that accept valueFrom in the README. Signed-off-by: Raman Tehlan --- charts/trueforge/README.md | 4 +++- charts/trueforge/templates/_helpers.tpl | 5 +++++ charts/trueforge/values.yaml | 2 ++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/charts/trueforge/README.md b/charts/trueforge/README.md index 11aca8a91..d80dc2068 100644 --- a/charts/trueforge/README.md +++ b/charts/trueforge/README.md @@ -316,7 +316,9 @@ 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` / `host` / `auth`, -`configs.oidc.clientSecret`. +`externalRedis.tls` (`caCert`, `cert`, `key`, `keyPassphrase`, `serverName`), +`externalRedis.sentinel.auth`, `configs.oidc.clientSecret`. +`externalRedis.tls.rejectUnauthorized` is a plain boolean (default true). `configs.oidc.issuerUrl` and `clientId` are plain strings only. **Bundled Postgres password** still uses Bitnami's API (`postgresql.auth.existingSecret`, diff --git a/charts/trueforge/templates/_helpers.tpl b/charts/trueforge/templates/_helpers.tpl index 64e3cde4a..060026b7c 100644 --- a/charts/trueforge/templates/_helpers.tpl +++ b/charts/trueforge/templates/_helpers.tpl @@ -492,6 +492,11 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv. {{- end -}} {{- $env = append $env (dict "name" "REDIS_TLS_ENABLED" "value" (ternary "true" "false" $tlsEnabled)) -}} {{- if $tlsEnabled -}} +{{- $rejectUnauthorized := true -}} +{{- if hasKey $tls "rejectUnauthorized" -}} +{{- $rejectUnauthorized = eq (toString $tls.rejectUnauthorized) "true" -}} +{{- end -}} +{{- $env = append $env (dict "name" "REDIS_TLS_REJECT_UNAUTHORIZED" "value" (ternary "true" "false" $rejectUnauthorized)) -}} {{- 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 -}} diff --git a/charts/trueforge/values.yaml b/charts/trueforge/values.yaml index cc4fd7275..54aa65a58 100644 --- a/charts/trueforge/values.yaml +++ b/charts/trueforge/values.yaml @@ -289,6 +289,8 @@ externalRedis: enabled: false ## Path or inline PEM caCert: "" + ## Verify the server certificate. Set false only for self-signed certs without a CA. + rejectUnauthorized: true serverName: "" cert: "" key: ""