Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8cc30c8
feat: add Redis Sentinel and TLS support in TrueForge
thesujai Sep 16, 2026
00ac363
fix: update return type of connectSentinelWithRetry to match createSe…
thesujai Sep 16, 2026
55f843d
docs: update README and values.yaml for external Redis configuration …
thesujai Sep 16, 2026
6c48eea
Regenerate OpenAPI document and SDKs
trueforge-dev-bot[bot] Sep 22, 2026
46d54ba
chart: fail on external Redis combinations the app ignores or rejects
ramantehlan Sep 22, 2026
1fc771f
feat: enhance Redis Sentinel and TLS support with exclusive transport…
thesujai Sep 22, 2026
800551b
Merge remote-tracking branch 'origin/feat/redis-connection-options' i…
thesujai Sep 22, 2026
7a77963
Merge branch 'main' into feat/redis-connection-options
thesujai Sep 22, 2026
b5b0b9d
Update sessions.ts
thesujai Sep 22, 2026
3efb052
feat: refine Redis Sentinel and TLS configuration with optional Redis…
thesujai Sep 22, 2026
5543903
chart: bump to 0.2.3, accept valueFrom for tls.serverName
ramantehlan Sep 22, 2026
38a8ff9
Merge branch 'main' into feat/redis-connection-options
ramantehlan Sep 22, 2026
5984b79
fix: prevent stopping heartbeat on transient errors in RequestReplyEx…
thesujai Sep 22, 2026
dd8b6c0
Address Chirag review on Redis Sentinel/TLS PR.
thesujai Sep 22, 2026
bec846a
refactor: update Redis URL encoding and change type definition for Re…
thesujai Sep 22, 2026
a3710bd
Bracket IPv6 hosts when building Redis standalone URLs.
thesujai Sep 22, 2026
2cffeee
chart: drop the obsolete rediss:// requirement for TLS
ramantehlan Sep 22, 2026
6d6143b
feat: enhance buildRedisStandaloneUrl to support IPv6 host formatting…
thesujai Sep 22, 2026
122e91d
Merge branch 'main' into feat/redis-connection-options
thesujai Sep 22, 2026
35d7020
fix: remove unnecessary line breaks in buildRedisStandaloneUrl functi…
thesujai Sep 22, 2026
4b04f86
chart: expose externalRedis.tls.rejectUnauthorized
ramantehlan Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/redis-sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@truefoundry/trueforge': minor
'@truefoundry/trueforge-core': minor
---

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.
2 changes: 1 addition & 1 deletion charts/trueforge/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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@<version> (must match version).
version: "0.2.2"
version: "0.2.3"
Comment thread
thesujai marked this conversation as resolved.
appVersion: "0.2.1"
kubeVersion: ">=1.25.0-0"
home: https://truefoundry.com
Expand Down
35 changes: 22 additions & 13 deletions charts/trueforge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -213,26 +213,33 @@ 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`, `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:
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 (not with url):
# 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

Expand Down Expand Up @@ -308,8 +315,10 @@ 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`,
`configs.oidc.clientSecret`.
`externalPostgres.user`, `externalPostgres.password`, `externalRedis.url` / `host` / `auth`,
`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`,
Expand Down Expand Up @@ -395,7 +404,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.
Expand Down
2 changes: 1 addition & 1 deletion charts/trueforge/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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) }}
Expand Down
86 changes: 77 additions & 9 deletions charts/trueforge/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ postgresql subchart (existingSecret override or <release>-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" -}}
Expand Down Expand Up @@ -433,20 +433,88 @@ 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)) -}}

{{- $externalRedis := .Values.externalRedis | default dict -}}
{{- $sentinel := $externalRedis.sentinel | default dict -}}
{{- $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" -}}
{{- 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 -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" .Values.externalRedis.url) | fromJson) -}}
{{- $sentinel := .Values.externalRedis.sentinel | default dict -}}
{{- if $sentinel.enabled -}}
{{- $_ := required "externalRedis.sentinel.hosts is required when externalRedis.sentinel.enabled is true" $sentinel.hosts -}}
{{- 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 -}}
{{- if and $externalRedis.url $externalRedis.host -}}
{{- fail "externalRedis.url and externalRedis.host are mutually exclusive" -}}
{{- end -}}
{{- 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) $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 $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_HOSTS" "value" $sentinel.hosts) -}}
{{- $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) -}}
{{- if $sentinel.password -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_PASSWORD" "field" "externalRedis.sentinel.password" "value" $sentinel.password) | fromJson) -}}
{{- $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 $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 -}}
Comment thread
cursor[bot] marked this conversation as resolved.
{{- 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)) -}}
{{- 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 -}}
{{- 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 -}}
{{- if $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) -}}
{{- 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 -}}
{{- else -}}
{{- fail "set redis.enabled or externalRedis.enabled" -}}
{{- end -}}

{{- if .Values.postgresql.enabled -}}
Expand Down
49 changes: 33 additions & 16 deletions charts/trueforge/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -271,23 +271,40 @@ 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).
## 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.
url: ""
# Redis Sentinel connection settings, injected as REDIS_SENTINEL_* env.
# url:
# valueFrom:
# secretKeyRef:
# name: my-redis-secret
# key: redis-url
## Standalone Redis host. Mutually exclusive with url and sentinel.
host: ""
port: 6379
db: 0
auth:
## string or valueFrom (host/sentinel modes; put credentials in url when using url)
username: ""
password: ""
tls:
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: ""
keyPassphrase: ""
sentinel:
## Mutually exclusive with url and host. Requires nodes + masterName when enabled.
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.
Expand Down
6 changes: 3 additions & 3 deletions packages/trueforge-core/src/request-reply/client.ts
Original file line number Diff line number Diff line change
@@ -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 { RedisClient } from './redisClient';
import type { JSONReply, JSONValue, PublishedRequest, RequestEnvelope } from './types';
import { jsonReplySchema } from './types';
import { heartbeatKey, replyKey, requestChannel, sleep } from './utils';
Expand All @@ -28,7 +28,7 @@ function parseReplyPayload(raw: string) {
return jsonReplySchema.parse(parsed);
}

async function getDelReply(redisClient: RedisClientType, rKey: string): Promise<JSONReply | null> {
async function getDelReply(redisClient: RedisClient, rKey: string): Promise<JSONReply | null> {
const raw = await redisClient.getDel(rKey);
if (raw === null) {
return null;
Expand All @@ -48,7 +48,7 @@ export async function redisRequest<T extends JSONValue>({
request,
options,
}: {
redis: RedisClientType;
redis: RedisClient;
executorId: string;
path: string;
request: RequestEnvelope<T>;
Expand Down
12 changes: 6 additions & 6 deletions packages/trueforge-core/src/request-reply/executor.ts
Original file line number Diff line number Diff line change
@@ -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 { RedisClient } from './redisClient';
import type { JSONReply, RequestHandler } from './types';
import { publishedRequestSchema } from './types';
import { heartbeatKey, requestChannel } from './utils';
Expand Down Expand Up @@ -44,8 +44,8 @@ export class RequestReplyExecutor {
readonly executorId: string;
/** `tfg:rr:req:<executorId>` — the channel this executor subscribes to. */
readonly channel: string;
private readonly redis: RedisClientType;
private readonly subscriberClient: RedisClientType;
private readonly redis: RedisClient;
private readonly subscriberClient: RedisClient;
private readonly logger: Logger;
private readonly heartbeatIntervalMs: number;
private readonly heartbeatTtlMs: number;
Expand Down Expand Up @@ -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: RedisClient;
/** Connected client to SUBSCRIBE on (duplicate or Sentinel). Caller owns its lifecycle. */
subscriberClient: RedisClientType;
subscriberClient: RedisClient;
requestHandler: RequestHandler;
onError?: RequestReplyErrorHandler | undefined;
logger: Logger;
Expand Down Expand Up @@ -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 });
Expand Down
1 change: 1 addition & 0 deletions packages/trueforge-core/src/request-reply/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { RedisClient } from './redisClient';
export { RequestReplyRouter } from './router';
export type { RouteHandler } from './router';
export { jsonReplySchema } from './types';
Expand Down
4 changes: 4 additions & 0 deletions packages/trueforge-core/src/request-reply/redisClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import type { RedisClientType, RedisSentinelType } from 'redis';

/** Standalone or Sentinel client for request-reply / command use. */
export type RedisClient = RedisClientType | RedisSentinelType;
Loading
Loading