Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 support (`REDIS_*` env and Helm `externalRedis`).
32 changes: 20 additions & 12 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 @@ -212,26 +212,34 @@ externalPostgres:
The server always runs peered (`STANDALONE=false`), so Redis is always required.
Bundled by default (`redis.enabled=true`, **auth disabled** — fine only when
Redis stays unreachable outside the cluster trust boundary). To use an
**external** Redis, set `redis.enabled=false` and provide `externalRedis.url`
as a string or `valueFrom.secretKeyRef`:
**external** Redis, set `redis.enabled=false` and provide `externalRedis.url` and/or `host`
(or Sentinel). Set `externalRedis.enabled=true` explicitly, or omit it when url/host/sentinel
is set (upgrade-compatible). Fields accept a string or `valueFrom.secretKeyRef`. When both
url and host are set, the app prefers `REDIS_URL`:

```yaml
redis:
enabled: false
externalRedis:
url:
valueFrom:
secretKeyRef:
name: my-redis-secret
key: redis-url
enabled: true
url: redis://:password@redis-master.databases.svc:6379
# or host + auth when url is unset:
# host: redis-master.databases.svc
# port: 6379
# auth:
# password:
# valueFrom:
# secretKeyRef:
# name: my-redis-secret
# key: redis-password
```

`redis.nameOverride` defaults to `trueforge-redis` so bundled Redis objects do
not share names with other Redis chart dependencies when this chart is a
dependency of some other chart.

For passworded Redis, prefer an external instance and load `REDIS_URL` via
`valueFrom`.
For passworded Redis, prefer an external instance and set `externalRedis.url` or
`externalRedis.auth` (and TLS/Sentinel as needed) via string or `valueFrom`.

## OIDC

Expand Down Expand Up @@ -278,7 +286,7 @@ chart does **not** create Secrets for chart-owned fields — supply

Fields that accept string | `valueFrom.secretKeyRef`:
`externalPostgres.host`, `externalPostgres.port`, `externalPostgres.database`,
`externalPostgres.user`, `externalPostgres.password`, `externalRedis.url`,
`externalPostgres.user`, `externalPostgres.password`, `externalRedis.url` / `host` / `auth`,
`configs.oidc.clientSecret`.
`configs.oidc.issuerUrl` and `clientId` are plain strings only.

Expand Down Expand Up @@ -362,7 +370,7 @@ also sets the `/tmp` `emptyDir.sizeLimit`.
- **Enable `configs.oidc`** — leaving it off grants shared admin to anyone who can reach the server.
- **Replace the `apiKey` placeholder** — create a Secret for `TRUEFORGE_API_KEY` and set `apiKey.valueFrom.secretKeyRef` (do not leave `placeholder-value-please-generate-your-own`).
- **Replace the bundled Postgres password** (`trueforge`) or set `postgresql.auth.existingSecret`.
- Treat bundled Redis (`redis.auth.enabled: false`) as cluster-internal only, or switch to external passworded Redis via `externalRedis.url`.
- Treat bundled Redis (`redis.auth.enabled: false`) as cluster-internal only, or switch to external passworded Redis via `externalRedis`.
- Set `server.publicBaseUrl` to the real public application URL before using MCP OAuth or OIDC (include a pathname when the UI is served under a stripped prefix).
- Prefer `valueFrom.secretKeyRef` for Postgres password, Redis URL, and OIDC client secret; do not commit secrets in values files.
- Prefer external managed Postgres/Redis over the bundled subcharts for production HA.
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
64 changes: 56 additions & 8 deletions charts/trueforge/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,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 @@ -399,20 +399,68 @@ fields, wires bundled Postgres/Redis, optional OIDC, then server.extraEnv.
{{- $env = append $env (dict "name" "STANDALONE" "value" "false") -}}
{{- $env = append $env (dict "name" "GRACEFUL_TIMEOUT_SECONDS" "value" (.Values.server.gracefulTimeoutSeconds | toString)) -}}

{{- if and .Values.redis.enabled .Values.externalRedis.enabled -}}
{{- fail "redis.enabled and externalRedis.enabled are mutually exclusive" -}}
{{- end -}}
{{- $externalRedis := .Values.externalRedis | default dict -}}
{{- $sentinel := $externalRedis.sentinel | default dict -}}
{{- $externalRedisActive := or $externalRedis.enabled $externalRedis.url $externalRedis.host (eq $sentinel.enabled true) -}}
{{- if .Values.redis.enabled -}}
{{- $env = append $env (dict "name" "REDIS_URL" "value" (include "trueforge.redis.bundledUrl" .)) -}}
{{- else -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" .Values.externalRedis.url) | fromJson) -}}
{{- $sentinel := .Values.externalRedis.sentinel | default dict -}}
{{- else if $externalRedisActive -}}
{{- if and (not $sentinel.enabled) (not $externalRedis.url) (not $externalRedis.host) -}}
{{- fail "externalRedis.url or externalRedis.host is required when using external Redis without sentinel" -}}
{{- end -}}
{{- if $externalRedis.url -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_URL" "field" "externalRedis.url" "value" $externalRedis.url) | fromJson) -}}
{{- end -}}
{{- if $externalRedis.host -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_HOST" "field" "externalRedis.host" "value" $externalRedis.host) | fromJson) -}}
{{- end -}}
Comment thread
cursor[bot] marked this conversation as resolved.
{{- $env = append $env (dict "name" "REDIS_PORT" "value" (($externalRedis.port | default 6379) | toString)) -}}
{{- $env = append $env (dict "name" "REDIS_DB" "value" (($externalRedis.db | default 0) | toString)) -}}
{{- $auth := $externalRedis.auth | default dict -}}
{{- if $auth.username -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_USERNAME" "field" "externalRedis.auth.username" "value" $auth.username) | fromJson) -}}
{{- end -}}
{{- if $auth.password -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_PASSWORD" "field" "externalRedis.auth.password" "value" $auth.password) | fromJson) -}}
{{- end -}}
{{- $tls := $externalRedis.tls | default dict -}}
{{- $env = append $env (dict "name" "REDIS_TLS_ENABLED" "value" (ternary "true" "false" (eq $tls.enabled true))) -}}
{{- if $tls.enabled -}}
{{- if $tls.caCert -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_CA_CERT" "field" "externalRedis.tls.caCert" "value" $tls.caCert) | fromJson) -}}
{{- end -}}
{{- if $tls.serverName -}}
{{- $env = append $env (dict "name" "REDIS_TLS_SERVERNAME" "value" $tls.serverName) -}}
{{- end -}}
{{- if $tls.cert -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_CERT" "field" "externalRedis.tls.cert" "value" $tls.cert) | fromJson) -}}
{{- end -}}
{{- if $tls.key -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_KEY" "field" "externalRedis.tls.key" "value" $tls.key) | fromJson) -}}
{{- end -}}
{{- if $tls.keyPassphrase -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_TLS_KEY_PASSPHRASE" "field" "externalRedis.tls.keyPassphrase" "value" $tls.keyPassphrase) | fromJson) -}}
{{- end -}}
{{- end -}}
{{- $env = append $env (dict "name" "REDIS_SENTINEL_ENABLED" "value" (ternary "true" "false" (eq $sentinel.enabled true))) -}}
{{- if $sentinel.enabled -}}
{{- $_ := required "externalRedis.sentinel.hosts is required when externalRedis.sentinel.enabled is true" $sentinel.hosts -}}
{{- $_ := required "externalRedis.sentinel.nodes is required when externalRedis.sentinel.enabled is true" (join "," $sentinel.nodes) -}}
{{- $_ := required "externalRedis.sentinel.masterName is required when externalRedis.sentinel.enabled is true" $sentinel.masterName -}}
{{- $env = append $env (dict "name" "REDIS_SENTINEL_HOSTS" "value" $sentinel.hosts) -}}
{{- $env = append $env (dict "name" "REDIS_SENTINEL_NODES" "value" (join "," $sentinel.nodes)) -}}
{{- $env = append $env (dict "name" "REDIS_SENTINEL_MASTER_NAME" "value" $sentinel.masterName) -}}
{{- if $sentinel.password -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_PASSWORD" "field" "externalRedis.sentinel.password" "value" $sentinel.password) | fromJson) -}}
{{- $sentinelAuth := $sentinel.auth | default dict -}}
{{- if $sentinelAuth.username -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_USERNAME" "field" "externalRedis.sentinel.auth.username" "value" $sentinelAuth.username) | fromJson) -}}
{{- end -}}
{{- if $sentinelAuth.password -}}
{{- $env = append $env (include "trueforge.env.fromStringOrValueFrom" (dict "name" "REDIS_SENTINEL_PASSWORD" "field" "externalRedis.sentinel.auth.password" "value" $sentinelAuth.password) | fromJson) -}}
{{- end -}}
{{- end -}}
{{- else -}}
{{- fail "set redis.enabled or externalRedis (enabled, url, host, or sentinel)" -}}
{{- end -}}

{{- if .Values.postgresql.enabled -}}
Expand Down
46 changes: 30 additions & 16 deletions charts/trueforge/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,9 @@ postgresql:
size: 8Gi
# --- Bundled Redis (Bitnami subchart) ------------------------------------------
# Rendered only when redis.enabled is true. Auth is disabled (dev default) so
# REDIS_URL is a plain redis://host:6379. Safe only while Redis stays inside
# the cluster trust boundary. For passworded Redis, disable the subchart and
# use externalRedis with a Secret.
# Auth is disabled (dev default) so REDIS_HOST points at the bundled master
# without credentials. Safe only while Redis stays inside the cluster trust
# boundary. For passworded Redis, disable the subchart and use externalRedis.
redis:
enabled: true
architecture: standalone
Expand Down Expand Up @@ -237,23 +237,37 @@ externalPostgres:
# Modes: disable | prefer | require | verify-ca | verify-full | no-verify.
sslMode: ""
externalRedis:
# Full connection URL, e.g. redis://:password@redis-master.databases.svc:6379.
# string, or { valueFrom: { secretKeyRef: { name, key } } }. Prefer valueFrom in prod.
## Use an external Redis instead of the bundled Redis (`redis.enabled` must be false).
## Set `enabled: true`, or set url/host/sentinel (enabled is optional for upgrade compat).
enabled: false
## Full connection URL, e.g. redis://:password@redis-master.databases.svc:6379.
## string or valueFrom. Preferred over host when set (app REDIS_URL).
url: ""
# Redis Sentinel connection settings, injected as REDIS_SENTINEL_* env.
# url:
# valueFrom:
# secretKeyRef:
# name: my-redis-secret
# key: redis-url
## Standalone Redis host (used when url is unset; optional when sentinel.enabled).
host: ""
port: 6379
db: 0
auth:
## string or valueFrom
username: ""
password: ""
tls:
enabled: false
## Path or inline PEM
caCert: ""
serverName: ""
cert: ""
key: ""
keyPassphrase: ""
sentinel:
enabled: false
# Comma-separated host:port list -> REDIS_SENTINEL_HOSTS.
hosts: ""
# Monitored master name -> REDIS_SENTINEL_MASTER_NAME.
## Array of host:port entries
nodes: []
masterName: ""
# string or valueFrom -> REDIS_SENTINEL_PASSWORD.
password: ""
auth:
## string or valueFrom
username: ""
password: ""
# Extra raw manifests deployed alongside the server, e.g. an Istio
# VirtualService, a Gateway, an Ingress, or a NetworkPolicy. Each entry is a
# full Kubernetes object rendered through `tpl`, so Helm templating works and
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 { RedisPeerClient } 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: RedisPeerClient, 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: RedisPeerClient;
executorId: string;
path: string;
request: RequestEnvelope<T>;
Expand Down
10 changes: 5 additions & 5 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 { RedisPeerClient } 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: RedisPeerClient;
private readonly subscriberClient: RedisPeerClient;
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: RedisPeerClient;
/** Connected client to SUBSCRIBE on (duplicate or Sentinel). Caller owns its lifecycle. */
subscriberClient: RedisClientType;
subscriberClient: RedisPeerClient;
requestHandler: RequestHandler;
onError?: RequestReplyErrorHandler | undefined;
logger: Logger;
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 { RedisPeerClient } 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 RedisPeerClient = RedisClientType | RedisSentinelType;
26 changes: 25 additions & 1 deletion packages/trueforge/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,33 @@ PORT=8790
# SQLITE_PATH=

## Used when STANDALONE=false (ignored in standalone). Redis connection URL for
## executor peering. Defaults to redis://localhost:6379 if unset.
## executor peering. Preferred over REDIS_HOST when set (may include userinfo).
## Required unless REDIS_HOST or Sentinel is configured.
## `docker-compose.yml` overrides this to redis://redis:6379 for the server container.
REDIS_URL=redis://localhost:6379
# Or host-based (used when REDIS_URL is unset):
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_USERNAME=
# REDIS_PASSWORD=

## Redis TLS (STANDALONE=false only). Applies to data nodes and Sentinel sockets.
# REDIS_TLS_ENABLED=false
# REDIS_TLS_CA_CERT=
# REDIS_TLS_REJECT_UNAUTHORIZED=true
# REDIS_TLS_SERVERNAME=
# REDIS_TLS_CERT=
# REDIS_TLS_KEY=
# REDIS_TLS_KEY_PASSPHRASE=

## Redis Sentinel (STANDALONE=false only). When enabled with nodes + master name,
## the client discovers the master via Sentinel; REDIS_USERNAME/PASSWORD apply to data nodes.
# REDIS_SENTINEL_ENABLED=false
# REDIS_SENTINEL_NODES=sentinel-0:26379,sentinel-1:26379
# REDIS_SENTINEL_MASTER_NAME=mymaster
# REDIS_SENTINEL_USERNAME=
# REDIS_SENTINEL_PASSWORD=

## Redis request/reply peering knobs (STANDALONE=false only). All optional.
## Max ms to wait for a peer executor's reply before failing with 424. Default 60000.
Expand Down
6 changes: 3 additions & 3 deletions packages/trueforge/src/apis/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -81,7 +81,7 @@ export interface SessionsRouterDeps {
resolveSkillStore: ResolveSkillStore;
resolveAgentStore: (c: Context) => IAgentStore;
resolveSandboxProviderStore: (c: Context) => ISandboxProviderStore;
redis?: RedisClientType | undefined;
redis?: RedisPeerClient | undefined;
requestReplyRouter: RequestReplyRouter;
resolveRequestContext: ResolveRequestContext;
logger: Logger;
Expand Down Expand Up @@ -127,7 +127,7 @@ export interface CancelTurnDeps {
activeTurns: ActiveTurnRegistry;
session: Pick<SessionHandle, 'session_id' | 'freezeTurn'>;
sessionStore: Pick<ISessionStore, 'getTurn'>;
redis?: RedisClientType | undefined;
redis?: RedisPeerClient | undefined;
logger: Pick<Logger, 'warn'>;
}

Expand Down
5 changes: 2 additions & 3 deletions packages/trueforge/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -207,7 +206,7 @@ export interface ServerDeps<TTransaction> {
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. */
Expand Down
Loading
Loading