feat(database): enhance PostgreSQL schema handling for multi-tenant support

- Added new environment variables `DATABASE_EXTENSIONS_SCHEMA` and `DATABASE_SEARCH_PATH_VIA_OPTIONS` to `.env.example` and `docker-compose.yml` for better management of PostgreSQL schemas in multi-tenant deployments.
- Updated the README and configuration documentation to explain the purpose and usage of the new variables, particularly for setups using Supabase.
- Implemented schema verification and initialization logic in the backend to ensure proper handling of tenant-specific schemas during database connections.
- Enhanced the installer and server settings forms to include the new configuration options, improving user experience and clarity.
This commit is contained in:
Dmitry Ng
2026-07-30 00:14:30 +03:00
parent 0da3892b0d
commit ca482686d7
16 changed files with 904 additions and 1558 deletions

View File

@@ -290,6 +290,12 @@ DATABASE_MAX_OPEN_CONNS=
DATABASE_MAX_IDLE_CONNS=
DATABASE_VECTOR_MAX_CONNS=
## Postgres schema handling, only used when TENANT_ID is set (Supabase needs
## DATABASE_EXTENSIONS_SCHEMA=extensions; its pooler may need the other one).
## See backend/docs/config.md -> "Multi-Instance Deployment (TENANT_ID)".
DATABASE_EXTENSIONS_SCHEMA=
DATABASE_SEARCH_PATH_VIA_OPTIONS=
## Graphiti knowledge graph settings
## Set GRAPHITI_ENABLED=true and GRAPHITI_URL=http://graphiti:8000 to enable embedded Graphiti
GRAPHITI_ENABLED=false

4
.vscode/launch.json vendored
View File

@@ -141,9 +141,9 @@
"LLM_SERVER_CONFIG_PATH": "${workspaceFolder}/examples/configs/openrouter.provider.yml",
"DATABASE_URL": "postgres://postgres:postgres@localhost:5432/pentagidb?sslmode=disable",
// Langfuse (optional) uncomment to enable
"LANGFUSE_BASE_URL": "http://localhost:4000",
// "LANGFUSE_BASE_URL": "http://localhost:4000",
// Observability (optional) uncomment to enable
"OTEL_HOST": "localhost:8148",
// "OTEL_HOST": "localhost:8148",
},
"args": [
"-flow", "0",

View File

@@ -706,7 +706,7 @@ Set it when several PentAGI installations share external resources: one PostgreS
| Area | Effect when `TENANT_ID=acme` |
| ---- | ---------------------------- |
| PostgreSQL | The instance creates and works inside schema `acme` instead of `public`; extensions stay shared in `public` |
| PostgreSQL | The instance creates and works inside schema `acme` instead of `public`; extensions stay shared in `DATABASE_EXTENSIONS_SCHEMA` (default `public`, `extensions` on Supabase) |
| Worker containers | `acme-pentagi-terminal-<flow>` instead of `pentagi-terminal-<flow>`; volumes and hostnames follow, and both carry a `pentagi.tenant` label |
| Knowledge graph | Graphiti/Neo4j group ids become `acme-flow-<id>` |
| Auth | Cookie and API token keys are derived from `COOKIE_SIGNING_SALT` **plus** the tenant, and the session cookie is renamed |
@@ -3018,6 +3018,22 @@ docker exec pgvector sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c \
GROUP BY 1, 2, 3 ORDER BY count DESC;"'
```
##### External PostgreSQL and schema handling
`DATABASE_URL` may point at any PostgreSQL instance, not only the bundled `pgvector` container. Two extra knobs apply when — and only when — `TENANT_ID` is set, because that is when PentAGI creates its own schema and rewrites the connection's `search_path`:
| Env var | Default | Purpose |
|---|---|---|
| `DATABASE_EXTENSIONS_SCHEMA` | `public` | Schema holding the shared `vector` and `pg_trgm` extensions that every tenant's `search_path` must reach |
| `DATABASE_SEARCH_PATH_VIA_OPTIONS` | `false` | Send the tenant `search_path` inside the `options` startup parameter instead of as a bare connection parameter |
**Supabase (cloud or self-hosted)** needs both of them considered, and is the reason they exist:
- Supabase installs its bundled extensions into an `extensions` schema instead of `public`, so set `DATABASE_EXTENSIONS_SCHEMA=extensions`. Without it, startup aborts with an error naming the schema where `vector` was actually found — no need to move a provider-managed extension with `ALTER EXTENSION`.
- Supabase's pooler (Supavisor) does not reliably forward a bare `search_path` connection parameter. Prefer a **direct** PostgreSQL connection: self-hosted, expose the `db` service port and bypass the `supavisor` service; cloud, use the "Direct connection" string (or the IPv4 add-on on IPv4-only networks). If the pooler cannot be bypassed, use its session mode and try `DATABASE_SEARCH_PATH_VIA_OPTIONS=true` — PentAGI verifies the effective schema on boot and refuses to start if it did not take effect, so a silent cross-tenant data mix-up is not possible.
Both settings are managed by the installer under *Server Settings*, next to `TENANT_ID` — see [Running Several Instances](#running-several-instances-tenant_id) for that scenario, and [Multi-Instance Deployment](backend/docs/config.md#multi-instance-deployment-tenant_id) for the full matrix, including the PgBouncer recipe (`pool_mode = session`, `ignore_startup_parameters`, per-tenant `connect_query`).
#### Frontend Configuration
Edit the configuration for `frontend` in `.vscode/launch.json` file:

View File

@@ -2,6 +2,7 @@ package main
import (
"context"
"database/sql"
"flag"
"log"
"os"
@@ -10,12 +11,14 @@ import (
"time"
"pentagi/pkg/config"
"pentagi/pkg/database"
"pentagi/pkg/providers/embeddings"
"pentagi/pkg/terminal"
"pentagi/pkg/version"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/joho/godotenv"
_ "github.com/lib/pq"
"github.com/sirupsen/logrus"
)
@@ -62,14 +65,32 @@ func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Initialize database connection pool
// Create this tenant's schema and repoint DATABASE_URL at it before any
// consumer reads the DSN. No-op when TENANT_ID is empty.
if err := database.EnsureTenantSchema(ctx, cfg); err != nil {
log.Fatalf("Tenant schema initialization failed: %v", err)
}
// Verify search_path on a short-lived database/sql connection before the
// long-lived pgxpool is opened — same guard the main server uses.
verifyDB, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Unable to open database for schema verification: %v", err)
}
verifyDB.SetMaxOpenConns(1)
if err := database.VerifySearchPath(ctx, verifyDB, cfg); err != nil {
verifyDB.Close()
log.Fatalf("Tenant schema verification failed: %v", err)
}
verifyDB.Close()
poolConfig, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
log.Fatalf("Unable to parse database URL: %v", err)
}
poolConfig.MaxConns = 10
poolConfig.MinConns = 2
poolConfig.MaxConns = min(int32(cfg.DBVectorMaxConns), 10)
poolConfig.MinConns = min(int32(cfg.DBMaxIdleConns), 2)
poolConfig.MaxConnLifetime = time.Hour
poolConfig.MaxConnIdleTime = 30 * time.Minute

View File

@@ -83,16 +83,26 @@ func main() {
_ = obs.Observer.Drain(drainCtx)
}()
// Initialize database connection
// Create this tenant's schema and repoint DATABASE_URL at it before any
// consumer reads the DSN. No-op when TENANT_ID is empty.
if err := database.EnsureTenantSchema(ctx, cfg); err != nil {
log.Fatalf("Tenant schema initialization failed: %v", err)
}
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatalf("Unable to open database: %v", err)
}
defer db.Close()
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(2)
db.SetMaxOpenConns(min(cfg.DBMaxOpenConns, 10))
db.SetMaxIdleConns(min(cfg.DBMaxIdleConns, 2))
db.SetConnMaxLifetime(time.Hour)
if err := database.VerifySearchPath(ctx, db, cfg); err != nil {
log.Fatalf("Tenant schema verification failed: %v", err)
}
queries := database.New(db)
terminal.PrintHeader("Function Tester (ftester)")

View File

@@ -4,6 +4,11 @@ import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"pentagi/pkg/config"
"pentagi/pkg/database"
_ "github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
@@ -46,38 +51,69 @@ func (p *processor) performPasswordReset(ctx context.Context, newPassword string
dbName = envVar.Value
}
// create connection string
connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
PostgreSQLHost, PostgreSQLPort, dbUser, dbPassword, dbName)
cfg := &config.Config{
DatabaseURL: fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
PostgreSQLHost, PostgreSQLPort, dbUser, dbPassword, dbName,
),
}
// open database connection
db, err := sql.Open("postgres", connStr)
if envVar, ok := p.state.GetVar("TENANT_ID"); ok {
cfg.TenantID = strings.TrimSpace(envVar.Value)
}
if err := cfg.ValidateTenantID(); err != nil {
return err
}
if envVar, ok := p.state.GetVar("DATABASE_EXTENSIONS_SCHEMA"); ok {
cfg.DatabaseExtensionsSchema = strings.TrimSpace(envVar.Value)
}
if envVar, ok := p.state.GetVar("DATABASE_SEARCH_PATH_VIA_OPTIONS"); ok && envVar.Value != "" {
viaOptions, err := strconv.ParseBool(envVar.Value)
if err != nil {
return fmt.Errorf("invalid DATABASE_SEARCH_PATH_VIA_OPTIONS %q: %w", envVar.Value, err)
}
cfg.DatabaseSearchPathViaOptions = viaOptions
}
// Point the DSN at the tenant schema before opening a connection. Without
// this, an UPDATE on unqualified "users" would hit public.users while the
// running instance owns <tenant>.users — a silent no-op or wrong-tenant write.
if err := database.RewriteDatabaseURLForTenant(cfg); err != nil {
return fmt.Errorf("failed to apply tenant search_path: %w", err)
}
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
defer db.Close()
// test connection
db.SetMaxOpenConns(1)
if err := db.PingContext(ctx); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
p.appendLog(fmt.Sprintf("Connected to PostgreSQL at %s:%s (database: %s)", PostgreSQLHost, PostgreSQLPort, dbName), ProductStackPentagi, state)
if err := database.VerifySearchPath(ctx, db, cfg); err != nil {
return fmt.Errorf("tenant schema verification failed: %w", err)
}
p.appendLog(fmt.Sprintf(
"Connected to PostgreSQL at %s:%s (database: %s, schema: %s)",
PostgreSQLHost, PostgreSQLPort, dbName, cfg.SchemaName(),
), ProductStackPentagi, state)
// hash the new password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
// update the admin user password and status
query := `UPDATE users SET password = $1, status = 'active' WHERE mail = $2`
result, err := db.ExecContext(ctx, query, string(hashedPassword), AdminEmail)
if err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
// check if any rows were affected
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("failed to get rows affected: %w", err)

View File

@@ -1996,21 +1996,23 @@ func (c *controller) ResetDockerConfig() *DockerConfig {
// ServerSettingsConfig represents PentAGI server settings configuration
type ServerSettingsConfig struct {
// direct form field mappings using loader.EnvVar
TenantID loader.EnvVar // TENANT_ID
LicenseKey loader.EnvVar // LICENSE_KEY
PprofAddr loader.EnvVar // PPROF_ADDR
ListenIP loader.EnvVar // PENTAGI_LISTEN_IP
ListenPort loader.EnvVar // PENTAGI_LISTEN_PORT
PublicURL loader.EnvVar // PUBLIC_URL
CorsOrigins loader.EnvVar // CORS_ORIGINS
CookieSigningSalt loader.EnvVar // COOKIE_SIGNING_SALT
ProxyURL loader.EnvVar // PROXY_URL
HTTPClientTimeout loader.EnvVar // HTTP_CLIENT_TIMEOUT
TerminalToolTimeout loader.EnvVar // TERMINAL_TOOL_TIMEOUT
ExternalSSLCAPath loader.EnvVar // EXTERNAL_SSL_CA_PATH
ExternalSSLInsecure loader.EnvVar // EXTERNAL_SSL_INSECURE
SSLDir loader.EnvVar // PENTAGI_SSL_DIR
DataDir loader.EnvVar // PENTAGI_DATA_DIR
TenantID loader.EnvVar // TENANT_ID
LicenseKey loader.EnvVar // LICENSE_KEY
PprofAddr loader.EnvVar // PPROF_ADDR
ListenIP loader.EnvVar // PENTAGI_LISTEN_IP
ListenPort loader.EnvVar // PENTAGI_LISTEN_PORT
PublicURL loader.EnvVar // PUBLIC_URL
CorsOrigins loader.EnvVar // CORS_ORIGINS
CookieSigningSalt loader.EnvVar // COOKIE_SIGNING_SALT
ProxyURL loader.EnvVar // PROXY_URL
HTTPClientTimeout loader.EnvVar // HTTP_CLIENT_TIMEOUT
TerminalToolTimeout loader.EnvVar // TERMINAL_TOOL_TIMEOUT
ExternalSSLCAPath loader.EnvVar // EXTERNAL_SSL_CA_PATH
ExternalSSLInsecure loader.EnvVar // EXTERNAL_SSL_INSECURE
SSLDir loader.EnvVar // PENTAGI_SSL_DIR
DataDir loader.EnvVar // PENTAGI_DATA_DIR
DatabaseExtensionsSchema loader.EnvVar // DATABASE_EXTENSIONS_SCHEMA
DatabaseSearchPathViaOpt loader.EnvVar // DATABASE_SEARCH_PATH_VIA_OPTIONS
// parsed credentials for proxy server (extracted from URLs)
ProxyUsername string
@@ -2035,20 +2037,24 @@ func (c *controller) GetServerSettingsConfig() *ServerSettingsConfig {
"EXTERNAL_SSL_INSECURE",
"PENTAGI_SSL_DIR",
"PENTAGI_DATA_DIR",
"DATABASE_EXTENSIONS_SCHEMA",
"DATABASE_SEARCH_PATH_VIA_OPTIONS",
})
defaults := map[string]string{
"LICENSE_KEY": "",
"PPROF_ADDR": "",
"PENTAGI_LISTEN_IP": "127.0.0.1",
"PENTAGI_LISTEN_PORT": "8443",
"PUBLIC_URL": "https://localhost:8443",
"CORS_ORIGINS": "https://localhost:8443",
"PENTAGI_DATA_DIR": "pentagi-data",
"PENTAGI_SSL_DIR": "pentagi-ssl",
"HTTP_CLIENT_TIMEOUT": "600",
"TERMINAL_TOOL_TIMEOUT": "600",
"EXTERNAL_SSL_INSECURE": "false",
"LICENSE_KEY": "",
"PPROF_ADDR": "",
"PENTAGI_LISTEN_IP": "127.0.0.1",
"PENTAGI_LISTEN_PORT": "8443",
"PUBLIC_URL": "https://localhost:8443",
"CORS_ORIGINS": "https://localhost:8443",
"PENTAGI_DATA_DIR": "pentagi-data",
"PENTAGI_SSL_DIR": "pentagi-ssl",
"HTTP_CLIENT_TIMEOUT": "600",
"TERMINAL_TOOL_TIMEOUT": "600",
"EXTERNAL_SSL_INSECURE": "false",
"DATABASE_EXTENSIONS_SCHEMA": "public",
"DATABASE_SEARCH_PATH_VIA_OPTIONS": "false",
}
for varName, defaultValue := range defaults {
@@ -2059,21 +2065,23 @@ func (c *controller) GetServerSettingsConfig() *ServerSettingsConfig {
}
cfg := &ServerSettingsConfig{
TenantID: vars["TENANT_ID"],
LicenseKey: vars["LICENSE_KEY"],
PprofAddr: vars["PPROF_ADDR"],
ListenIP: vars["PENTAGI_LISTEN_IP"],
ListenPort: vars["PENTAGI_LISTEN_PORT"],
PublicURL: vars["PUBLIC_URL"],
CorsOrigins: vars["CORS_ORIGINS"],
CookieSigningSalt: vars["COOKIE_SIGNING_SALT"],
ProxyURL: vars["PROXY_URL"],
HTTPClientTimeout: vars["HTTP_CLIENT_TIMEOUT"],
TerminalToolTimeout: vars["TERMINAL_TOOL_TIMEOUT"],
ExternalSSLCAPath: vars["EXTERNAL_SSL_CA_PATH"],
ExternalSSLInsecure: vars["EXTERNAL_SSL_INSECURE"],
SSLDir: vars["PENTAGI_SSL_DIR"],
DataDir: vars["PENTAGI_DATA_DIR"],
TenantID: vars["TENANT_ID"],
LicenseKey: vars["LICENSE_KEY"],
PprofAddr: vars["PPROF_ADDR"],
ListenIP: vars["PENTAGI_LISTEN_IP"],
ListenPort: vars["PENTAGI_LISTEN_PORT"],
PublicURL: vars["PUBLIC_URL"],
CorsOrigins: vars["CORS_ORIGINS"],
CookieSigningSalt: vars["COOKIE_SIGNING_SALT"],
ProxyURL: vars["PROXY_URL"],
HTTPClientTimeout: vars["HTTP_CLIENT_TIMEOUT"],
TerminalToolTimeout: vars["TERMINAL_TOOL_TIMEOUT"],
ExternalSSLCAPath: vars["EXTERNAL_SSL_CA_PATH"],
ExternalSSLInsecure: vars["EXTERNAL_SSL_INSECURE"],
SSLDir: vars["PENTAGI_SSL_DIR"],
DataDir: vars["PENTAGI_DATA_DIR"],
DatabaseExtensionsSchema: vars["DATABASE_EXTENSIONS_SCHEMA"],
DatabaseSearchPathViaOpt: vars["DATABASE_SEARCH_PATH_VIA_OPTIONS"],
}
// split proxy URL into credentials + naked URL for UI
@@ -2101,21 +2109,23 @@ func (c *controller) UpdateServerSettingsConfig(config *ServerSettingsConfig) er
}
updates := map[string]string{
"TENANT_ID": config.TenantID.Value,
"LICENSE_KEY": config.LicenseKey.Value,
"PPROF_ADDR": config.PprofAddr.Value,
"PENTAGI_LISTEN_IP": config.ListenIP.Value,
"PENTAGI_LISTEN_PORT": config.ListenPort.Value,
"PUBLIC_URL": config.PublicURL.Value,
"CORS_ORIGINS": config.CorsOrigins.Value,
"COOKIE_SIGNING_SALT": config.CookieSigningSalt.Value,
"PROXY_URL": proxyURL,
"HTTP_CLIENT_TIMEOUT": config.HTTPClientTimeout.Value,
"TERMINAL_TOOL_TIMEOUT": config.TerminalToolTimeout.Value,
"EXTERNAL_SSL_CA_PATH": config.ExternalSSLCAPath.Value,
"EXTERNAL_SSL_INSECURE": config.ExternalSSLInsecure.Value,
"PENTAGI_SSL_DIR": config.SSLDir.Value,
"PENTAGI_DATA_DIR": config.DataDir.Value,
"TENANT_ID": config.TenantID.Value,
"LICENSE_KEY": config.LicenseKey.Value,
"PPROF_ADDR": config.PprofAddr.Value,
"PENTAGI_LISTEN_IP": config.ListenIP.Value,
"PENTAGI_LISTEN_PORT": config.ListenPort.Value,
"PUBLIC_URL": config.PublicURL.Value,
"CORS_ORIGINS": config.CorsOrigins.Value,
"COOKIE_SIGNING_SALT": config.CookieSigningSalt.Value,
"PROXY_URL": proxyURL,
"HTTP_CLIENT_TIMEOUT": config.HTTPClientTimeout.Value,
"TERMINAL_TOOL_TIMEOUT": config.TerminalToolTimeout.Value,
"EXTERNAL_SSL_CA_PATH": config.ExternalSSLCAPath.Value,
"EXTERNAL_SSL_INSECURE": config.ExternalSSLInsecure.Value,
"PENTAGI_SSL_DIR": config.SSLDir.Value,
"PENTAGI_DATA_DIR": config.DataDir.Value,
"DATABASE_EXTENSIONS_SCHEMA": config.DatabaseExtensionsSchema.Value,
"DATABASE_SEARCH_PATH_VIA_OPTIONS": config.DatabaseSearchPathViaOpt.Value,
}
if err := c.SetVars(updates); err != nil {
@@ -2143,6 +2153,8 @@ func (c *controller) ResetServerSettingsConfig() *ServerSettingsConfig {
"EXTERNAL_SSL_INSECURE",
"PENTAGI_SSL_DIR",
"PENTAGI_DATA_DIR",
"DATABASE_EXTENSIONS_SCHEMA",
"DATABASE_SEARCH_PATH_VIA_OPTIONS",
}
if err := c.ResetVars(vars); err != nil {
@@ -2395,6 +2407,8 @@ func (c *controller) getVariableDescription(varName string) string {
"PENTAGI_DOCKER_CERT_PATH": locale.EnvDesc_PENTAGI_DOCKER_CERT_PATH,
"PENTAGI_LLM_SERVER_CONFIG_PATH": locale.EnvDesc_PENTAGI_LLM_SERVER_CONFIG_PATH,
"PENTAGI_OLLAMA_SERVER_CONFIG_PATH": locale.EnvDesc_PENTAGI_OLLAMA_SERVER_CONFIG_PATH,
"DATABASE_EXTENSIONS_SCHEMA": locale.EnvDesc_DATABASE_EXTENSIONS_SCHEMA,
"DATABASE_SEARCH_PATH_VIA_OPTIONS": locale.EnvDesc_DATABASE_SEARCH_PATH_VIA_OPTIONS,
"STATIC_DIR": locale.EnvDesc_STATIC_DIR,
"STATIC_URL": locale.EnvDesc_STATIC_URL,
@@ -2610,26 +2624,28 @@ var criticalVariables = map[string]bool{
"MAX_LIMITED_AGENT_TOOL_CALLS": true,
"AGENT_PLANNING_STEP_ENABLED": true,
"TENANT_ID": true,
"LICENSE_KEY": true,
"PPROF_ADDR": true,
"PENTAGI_LISTEN_IP": true,
"PENTAGI_LISTEN_PORT": true,
"PUBLIC_URL": true,
"CORS_ORIGINS": true,
"COOKIE_SIGNING_SALT": true,
"PROXY_URL": true,
"EXTERNAL_SSL_CA_PATH": true,
"EXTERNAL_SSL_INSECURE": true,
"STATIC_DIR": true,
"STATIC_URL": true,
"SERVER_PORT": true,
"SERVER_HOST": true,
"SERVER_SSL_CRT": true,
"SERVER_SSL_KEY": true,
"SERVER_USE_SSL": true,
"PENTAGI_SSL_DIR": true,
"PENTAGI_DATA_DIR": true,
"TENANT_ID": true,
"LICENSE_KEY": true,
"PPROF_ADDR": true,
"PENTAGI_LISTEN_IP": true,
"PENTAGI_LISTEN_PORT": true,
"PUBLIC_URL": true,
"CORS_ORIGINS": true,
"COOKIE_SIGNING_SALT": true,
"PROXY_URL": true,
"EXTERNAL_SSL_CA_PATH": true,
"EXTERNAL_SSL_INSECURE": true,
"STATIC_DIR": true,
"STATIC_URL": true,
"SERVER_PORT": true,
"SERVER_HOST": true,
"SERVER_SSL_CRT": true,
"SERVER_SSL_KEY": true,
"SERVER_USE_SSL": true,
"PENTAGI_SSL_DIR": true,
"PENTAGI_DATA_DIR": true,
"DATABASE_EXTENSIONS_SCHEMA": true,
"DATABASE_SEARCH_PATH_VIA_OPTIONS": true,
// scraper settings
"SCRAPER_PUBLIC_URL": true,

View File

@@ -1293,23 +1293,31 @@ Examples:
ServerSettingsCookieSigningSalt = "Cookie Signing Salt"
ServerSettingsCookieSigningSaltDesc = "Secret used to sign cookies (keep private)"
ServerSettingsDatabaseExtensionsSchema = "Database Extensions Schema"
ServerSettingsDatabaseExtensionsSchemaDesc = "Schema holding shared extensions when Tenant ID is set (e.g., public, extensions for Supabase)"
ServerSettingsDatabaseSearchPathViaOptions = "Search Path via Options"
ServerSettingsDatabaseSearchPathViaOptionsDesc = "Send the tenant search_path inside the options startup parameter (needed by some poolers)"
// Hints for fields overview
ServerSettingsLicenseKeyHint = "License Key"
ServerSettingsTenantIDHint = "Tenant ID"
ServerSettingsPprofAddrHint = "pprof Address"
ServerSettingsHostHint = "Listen IP"
ServerSettingsPortHint = "Listen Port"
ServerSettingsPublicURLHint = "Public URL"
ServerSettingsCORSOriginsHint = "CORS Origins"
ServerSettingsProxyURLHint = "Proxy URL"
ServerSettingsProxyUsernameHint = "Proxy Username"
ServerSettingsProxyPasswordHint = "Proxy Password"
ServerSettingsHTTPClientTimeoutHint = "HTTP Timeout"
ServerSettingsTerminalToolTimeoutHint = "Terminal Timeout"
ServerSettingsExternalSSLCAPathHint = "Custom CA Path"
ServerSettingsExternalSSLInsecureHint = "Skip SSL Verification"
ServerSettingsSSLDirHint = "SSL Directory"
ServerSettingsDataDirHint = "Data Directory"
ServerSettingsLicenseKeyHint = "License Key"
ServerSettingsTenantIDHint = "Tenant ID"
ServerSettingsPprofAddrHint = "pprof Address"
ServerSettingsHostHint = "Listen IP"
ServerSettingsPortHint = "Listen Port"
ServerSettingsPublicURLHint = "Public URL"
ServerSettingsCORSOriginsHint = "CORS Origins"
ServerSettingsProxyURLHint = "Proxy URL"
ServerSettingsProxyUsernameHint = "Proxy Username"
ServerSettingsProxyPasswordHint = "Proxy Password"
ServerSettingsHTTPClientTimeoutHint = "HTTP Timeout"
ServerSettingsTerminalToolTimeoutHint = "Terminal Timeout"
ServerSettingsExternalSSLCAPathHint = "Custom CA Path"
ServerSettingsExternalSSLInsecureHint = "Skip SSL Verification"
ServerSettingsSSLDirHint = "SSL Directory"
ServerSettingsDataDirHint = "Data Directory"
ServerSettingsDatabaseExtensionsSchemaHint = "Extensions Schema"
ServerSettingsDatabaseSearchPathViaOptionsHint = "Search Path via Options"
// Help texts per-field
ServerSettingsGeneralHelp = `PentAGI exposes its web UI via Docker with configurable host and port.
@@ -1399,6 +1407,20 @@ When enabled, all certificate validation is bypassed, making connections vulnera
ServerSettingsDataDirHelp = `Host directory for persistent data. PentAGI stores agent artifacts under flow-N subdirectories, which map to /work inside worker containers.`
ServerSettingsCookieSigningSaltHelp = `Secret salt used to sign cookies. Keep it private.`
ServerSettingsDatabaseExtensionsSchemaHelp = `PostgreSQL schema that holds the shared extensions (vector, pg_trgm) every tenant must reach through its search_path.
Only used when Tenant ID is set; leave empty for the default "public", which is where a stock PostgreSQL install keeps them. Set it when your database follows another convention — Supabase installs its extensions into "extensions", and startup aborts with a message naming the schema it found if this does not match.
Examples:
• public
• extensions`
ServerSettingsDatabaseSearchPathViaOptionsHelp = `Sends the tenant search_path as options=--search_path=<value> instead of a bare search_path connection parameter.
Only used when Tenant ID is set. Keep it false for a direct PostgreSQL connection. Enable it when connecting through a pooler that forwards the "options" startup parameter but drops an unrecognized bare search_path — reported to be the case for some versions of Supabase's Supavisor. It is not guaranteed to work: startup fails with a clear schema-mismatch error if the value never reaches the backend.
Values: true, false`
)
// Human-in-the-loop screen strings
@@ -2410,6 +2432,8 @@ const (
EnvDesc_PUBLIC_URL = "PentAGI Public URL"
EnvDesc_CORS_ORIGINS = "PentAGI CORS Origins"
EnvDesc_COOKIE_SIGNING_SALT = "PentAGI Cookie Signing Salt"
EnvDesc_DATABASE_EXTENSIONS_SCHEMA = "PostgreSQL Extensions Schema"
EnvDesc_DATABASE_SEARCH_PATH_VIA_OPTIONS = "PostgreSQL Search Path via Options"
EnvDesc_PROXY_URL = "HTTP/HTTPS Proxy URL"
EnvDesc_HTTP_CLIENT_TIMEOUT = "HTTP Client Timeout (seconds)"
EnvDesc_TERMINAL_TOOL_TIMEOUT = "Terminal Tool Timeout (seconds)"

View File

@@ -3,6 +3,7 @@ package models
import (
"fmt"
"net"
"regexp"
"strconv"
"strings"
@@ -18,6 +19,11 @@ import (
"github.com/vxcontrol/cloud/sdk"
)
// schemaNameRegex keeps DATABASE_EXTENSIONS_SCHEMA within PostgreSQL's
// unquoted-identifier rules and 63-byte limit, so a typo surfaces here rather
// than as a failed CREATE EXTENSION on first boot.
var schemaNameRegex = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]{0,62}$`)
// ServerSettingsFormModel represents the PentAGI server settings configuration form
type ServerSettingsFormModel struct {
*BaseScreen
@@ -162,6 +168,20 @@ func (m *ServerSettingsFormModel) BuildForm() tea.Cmd {
true,
))
fields = append(fields, m.createTextField("database_extensions_schema",
locale.ServerSettingsDatabaseExtensionsSchema,
locale.ServerSettingsDatabaseExtensionsSchemaDesc,
config.DatabaseExtensionsSchema,
false,
))
fields = append(fields, m.createTextField("database_search_path_via_options",
locale.ServerSettingsDatabaseSearchPathViaOptions,
locale.ServerSettingsDatabaseSearchPathViaOptionsDesc,
config.DatabaseSearchPathViaOpt,
false,
))
m.SetFormFields(fields)
return nil
}
@@ -253,6 +273,26 @@ func (m *ServerSettingsFormModel) GetCurrentConfiguration() string {
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsTenantIDHint, tenantID))
}
// both only take effect with a tenant configured, so keep them out of the
// overview of a single-instance deployment
if cfg.TenantID.Value != "" {
if schema := cfg.DatabaseExtensionsSchema.Value; schema != "" {
schema = m.GetStyles().Info.Render(schema)
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsDatabaseExtensionsSchemaHint, schema))
} else if schema := cfg.DatabaseExtensionsSchema.Default; schema != "" {
schema = m.GetStyles().Muted.Render(schema)
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsDatabaseExtensionsSchemaHint, schema))
}
if viaOptions := cfg.DatabaseSearchPathViaOpt.Value; viaOptions == "true" {
viaOptions = m.GetStyles().Info.Render("Enabled")
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsDatabaseSearchPathViaOptionsHint, viaOptions))
} else {
viaOptions = m.GetStyles().Muted.Render("Disabled")
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsDatabaseSearchPathViaOptionsHint, viaOptions))
}
}
if pprofAddr := cfg.PprofAddr.Value; pprofAddr != "" {
pprofAddr = m.GetStyles().Info.Render(pprofAddr)
sections = append(sections, fmt.Sprintf("• %s: %s", locale.ServerSettingsPprofAddrHint, pprofAddr))
@@ -416,6 +456,10 @@ func (m *ServerSettingsFormModel) GetHelpContent() string {
sections = append(sections, locale.ServerSettingsDataDirHelp)
case "pentagi_cookie_signing_salt":
sections = append(sections, locale.ServerSettingsCookieSigningSaltHelp)
case "database_extensions_schema":
sections = append(sections, locale.ServerSettingsDatabaseExtensionsSchemaHelp)
case "database_search_path_via_options":
sections = append(sections, locale.ServerSettingsDatabaseSearchPathViaOptionsHelp)
default:
sections = append(sections, locale.ServerSettingsFormOverview)
}
@@ -429,21 +473,23 @@ func (m *ServerSettingsFormModel) HandleSave() error {
fields := m.GetFormFields()
newCfg := &controller.ServerSettingsConfig{
TenantID: cfg.TenantID,
LicenseKey: cfg.LicenseKey,
PprofAddr: cfg.PprofAddr,
ListenIP: cfg.ListenIP,
ListenPort: cfg.ListenPort,
CorsOrigins: cfg.CorsOrigins,
CookieSigningSalt: cfg.CookieSigningSalt,
ProxyURL: cfg.ProxyURL,
HTTPClientTimeout: cfg.HTTPClientTimeout,
TerminalToolTimeout: cfg.TerminalToolTimeout,
ExternalSSLCAPath: cfg.ExternalSSLCAPath,
ExternalSSLInsecure: cfg.ExternalSSLInsecure,
SSLDir: cfg.SSLDir,
DataDir: cfg.DataDir,
PublicURL: cfg.PublicURL,
TenantID: cfg.TenantID,
LicenseKey: cfg.LicenseKey,
PprofAddr: cfg.PprofAddr,
ListenIP: cfg.ListenIP,
ListenPort: cfg.ListenPort,
CorsOrigins: cfg.CorsOrigins,
CookieSigningSalt: cfg.CookieSigningSalt,
ProxyURL: cfg.ProxyURL,
HTTPClientTimeout: cfg.HTTPClientTimeout,
TerminalToolTimeout: cfg.TerminalToolTimeout,
ExternalSSLCAPath: cfg.ExternalSSLCAPath,
ExternalSSLInsecure: cfg.ExternalSSLInsecure,
SSLDir: cfg.SSLDir,
DataDir: cfg.DataDir,
PublicURL: cfg.PublicURL,
DatabaseExtensionsSchema: cfg.DatabaseExtensionsSchema,
DatabaseSearchPathViaOpt: cfg.DatabaseSearchPathViaOpt,
}
for _, field := range fields {
@@ -521,6 +567,16 @@ func (m *ServerSettingsFormModel) HandleSave() error {
newCfg.DataDir.Value = value
case "pentagi_cookie_signing_salt":
newCfg.CookieSigningSalt.Value = value
case "database_extensions_schema":
if value != "" && !schemaNameRegex.MatchString(value) {
return fmt.Errorf("invalid extensions schema: must match %s", schemaNameRegex.String())
}
newCfg.DatabaseExtensionsSchema.Value = value
case "database_search_path_via_options":
if value != "" && value != "true" && value != "false" {
return fmt.Errorf("invalid value for search path via options: must be 'true' or 'false'")
}
newCfg.DatabaseSearchPathViaOpt.Value = value
}
}

View File

@@ -96,7 +96,7 @@ func main() {
// Create this tenant's schema and repoint DATABASE_URL at it before any
// consumer reads the DSN. No-op when TENANT_ID is empty.
if err := ensureTenantSchema(ctx, cfg); err != nil {
if err := database.EnsureTenantSchema(ctx, cfg); err != nil {
logrus.WithError(err).Fatal("Tenant schema initialization failed")
}
@@ -109,7 +109,7 @@ func main() {
db.SetMaxIdleConns(cfg.DBMaxIdleConns)
db.SetConnMaxLifetime(time.Hour)
if err := verifySearchPath(ctx, db, cfg); err != nil {
if err := database.VerifySearchPath(ctx, db, cfg); err != nil {
logrus.WithError(err).Fatal("Tenant schema verification failed")
}
@@ -146,10 +146,16 @@ func main() {
logrus.WithError(err).Fatal("Database dialect configuration failed")
}
// goose's own queries are unqualified, so without this a fresh tenant
// schema silently inherits public's version table via search_path and
// skips its migrations. See pkg/database/tenant.go for the
// schema/search_path setup.
goose.SetTableName(cfg.SchemaName() + ".goose_db_version")
// Hold an advisory lock so simultaneous boots cannot execute the same
// migration set concurrently; the initial migration uses bare CREATE TABLE,
// so the loser would otherwise abort on "relation already exists".
if err := runMigrations(ctx, db, cfg, func(db *sql.DB) error {
if err := database.RunMigrations(ctx, db, cfg, func(db *sql.DB) error {
return goose.Up(db, "sql")
}); err != nil {
// Fatal: continuing on a half-migrated schema and then serving traffic is

View File

@@ -11,9 +11,15 @@ This document serves as a comprehensive guide to the configuration system in Pen
- [Still Server-Managed](#still-server-managed)
- [General Settings](#general-settings)
- [Multi-Instance Deployment (`TENANT_ID`)](#multi-instance-deployment-tenant_id)
- [What the application namespaces automatically](#what-the-application-namespaces-automatically)
- [What stays the operator's responsibility](#what-stays-the-operators-responsibility)
- [Deployment topologies](#deployment-topologies)
- [Extensions installed outside `public` (`DATABASE_EXTENSIONS_SCHEMA`)](#extensions-installed-outside-public-database_extensions_schema)
- [Multi-tenant PostgreSQL access through PgBouncer](#multi-tenant-postgresql-access-through-pgbouncer)
- [Multi-tenant PostgreSQL through Supabase's Supavisor pooler (`DATABASE_SEARCH_PATH_VIA_OPTIONS`)](#multi-tenant-postgresql-through-supabases-supavisor-pooler-database_search_path_via_options)
- [Usage Details](#usage-details)
- [Docker Settings](#docker-settings)
- [Worker Docker Access (DOCKER_INSIDE_*)](#worker-docker-access-docker_inside_)
- [Worker Docker Access (`DOCKER_INSIDE_*`)](#worker-docker-access-docker_inside_)
- [Usage Details](#usage-details-1)
- [Server Settings](#server-settings)
- [Usage Details](#usage-details-2)
@@ -136,6 +142,8 @@ These settings control basic application behavior and are foundational for the s
| Option | Environment Variable | Default Value | Description |
| ---------------- | --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| DatabaseURL | `DATABASE_URL` | `postgres://pentagiuser:pentagipass@pgvector:5432/pentagidb?sslmode=disable` | Connection string for the PostgreSQL database with pgvector extension |
| DatabaseExtensionsSchema | `DATABASE_EXTENSIONS_SCHEMA` | `public` | Schema every tenant's search_path must include for shared extensions (`vector`, `pg_trgm`) to resolve. Only used when `TenantID` is set. Override for databases that install extensions elsewhere by convention, e.g. Supabase uses `extensions`. See [Extensions installed outside `public`](#extensions-installed-outside-public-database_extensions_schema). |
| DatabaseSearchPathViaOptions | `DATABASE_SEARCH_PATH_VIA_OPTIONS` | `false` | Sends the tenant search_path as `options=--search_path=<value>` instead of a bare `search_path` parameter. Only used when `TenantID` is set. For poolers that forward `options` but drop an unrecognized bare `search_path` (e.g. some Supabase Supavisor versions). See [Multi-tenant PostgreSQL through Supabase's Supavisor pooler](#multi-tenant-postgresql-through-supabases-supavisor-pooler-database_search_path_via_options). |
| DBMaxOpenConns | `DATABASE_MAX_OPEN_CONNS` | `25` | Maximum open connections in the shared `sql.DB` pool (sqlc + GORM combined). See [database.md §Connection Pooling](database.md#connection-pooling). |
| DBMaxIdleConns | `DATABASE_MAX_IDLE_CONNS` | `5` | Maximum idle connections kept open between requests |
| DBVectorMaxConns | `DATABASE_VECTOR_MAX_CONNS` | `10` | Maximum connections in the shared `pgxpool` for all pgvector stores |
@@ -171,7 +179,7 @@ Hyphens are excluded deliberately: they separate the tenant from the rest of a g
| Area | Effect |
| --- | --- |
| PostgreSQL | A schema named after the tenant is created on boot and `search_path` is set to `<tenant>,public`; the DSN is rewritten once so sqlc, GORM, goose and the pgvector pool all follow. Extensions (`vector`, `pg_trgm`) stay shared in `public`. |
| PostgreSQL | A schema named after the tenant is created on boot and `search_path` is set to `<tenant>,<DATABASE_EXTENSIONS_SCHEMA>`; the DSN is rewritten once so sqlc, GORM, goose and the pgvector pool all follow. Extensions (`vector`, `pg_trgm`) stay shared in `DATABASE_EXTENSIONS_SCHEMA` (default `public`). |
| Worker containers | Sandbox container names become `<tenant>-pentagi-terminal-<flow>`; the per-flow volume and the container hostname derive from that name automatically. Both are labelled `pentagi.tenant`, so daemon-wide sweeps can filter by owner. |
| Host ports | Per-flow sandbox ports are allocated from `DOCKER_PORTS_BASE` (default `28000`), giving each instance the window `[base, base+2000)`. |
| Knowledge graph | Graphiti/Neo4j group ids become `<tenant>-flow-<id>`. The GraphQL API contract is unchanged — clients still send `flow-<id>` and the server rebuilds the namespaced key internally. |
@@ -188,7 +196,7 @@ Hyphens are excluded deliberately: they separate the tenant from the rest of a g
| `DOCKER_NETWORK` | Instances may legitimately share one Docker network; the application never renames it. Set it explicitly if you want separate networks. |
| Published ports | `PENTAGI_LISTEN_PORT`, `PGVECTOR_LISTEN_PORT`, `SCRAPER_LISTEN_PORT`, `PPROF_ADDR` and `DOCKER_PORTS_BASE` are host-level resources, not string namespaces. |
| `INSTALLATION_ID` | Unique per installation, or left empty to be generated once and cached in `DATA_DIR`. |
| Database privileges | The configured user needs `CREATE SCHEMA`, and on first boot `CREATE EXTENSION` — unless an administrator pre-installed `vector` and `pg_trgm` into `public`. |
| Database privileges | The configured user needs `CREATE SCHEMA`, and on first boot `CREATE EXTENSION` — unless an administrator pre-installed `vector` and `pg_trgm` into `DATABASE_EXTENSIONS_SCHEMA` (default `public`). |
The values actually in effect are written to the startup log under `Instance identity` (`tenant_id`, `data_dir`, `schema`, `installation_id`), which is the quickest way to confirm two instances are not sharing something they should not.
@@ -205,6 +213,53 @@ The installer provisions **one** instance per server; it does not manage several
**Upgrading an existing deployment.** Leave `TENANT_ID` empty. The instance keeps using `public` and its current data directory; no migration is required. Setting `TENANT_ID` on an existing installation points it at a **new, empty schema** — the data in `public` is not migrated and will appear to be gone. Do not point an existing `public` deployment at a `search_path` that lists another tenant's schema.
#### Extensions installed outside `public` (`DATABASE_EXTENSIONS_SCHEMA`)
`ensureTenantSchema` requires `vector` and `pg_trgm` to already live in (or be creatable in) one schema common to every tenant, because it must be part of every tenant's `search_path`. That schema defaults to `public`, which is where a stock PostgreSQL/`docker-compose.yml` install keeps them.
Managed providers do not always follow that convention. **Supabase** (cloud and self-hosted) installs its bundled extensions into a dedicated `extensions` schema instead, and its default roles get `extensions` added to their `search_path` for exactly that reason. Pointing `DATABASE_URL` at such a database with `TENANT_ID` set fails fast on boot:
```
Tenant schema initialization failed: extension "vector" is installed in schema "extensions",
but multi-tenant mode requires it in "public" so every tenant can reach it; either run
ALTER EXTENSION vector SET SCHEMA public, or set DATABASE_EXTENSIONS_SCHEMA=extensions
to match where it already lives
```
Set `DATABASE_EXTENSIONS_SCHEMA=extensions` (or whatever schema the error reports) instead of moving the extension with `ALTER EXTENSION ... SET SCHEMA` — the schema only needs to be part of the `search_path` PentAGI computes (`<tenant>,<DATABASE_EXTENSIONS_SCHEMA>`), moving a provider-managed extension out of its documented location is unnecessary and risks breaking whatever else that provider expects to find it there.
#### Multi-tenant PostgreSQL access through PgBouncer
`DATABASE_URL` can point at a PgBouncer instance instead of PostgreSQL directly, but three things have to be true, independent of each other:
1. **`pool_mode = session` on the PgBouncer side.** PentAGI holds a `pg_advisory_lock`/`pg_advisory_unlock` pair on one dedicated connection across the whole tenant-bootstrap + migration sequence (`backend/pkg/database/tenant.go`), and `pgx` (used by the pgvector pool) caches server-side prepared statements by default. Both break silently under `transaction`/`statement` pooling, because PgBouncer is then free to hand the client a different backend connection between statements. This requirement is unrelated to tenancy — it applies even with `TENANT_ID` empty.
2. **`ignore_startup_parameters = search_path` in `pgbouncer.ini`.** With a tenant configured, PentAGI's own DSN already carries `?search_path=<tenant>,<DATABASE_EXTENSIONS_SCHEMA>` (see the table above). PgBouncer validates startup parameters from the client against a small built-in allowlist and rejects anything else with `unsupported startup parameter: search_path` unless it is explicitly ignored. Ignoring it does **not** apply the value — it only stops PgBouncer from rejecting the connection — so this step alone is not sufficient; see the next point.
3. **A `connect_query` per tenant in PgBouncer's `[databases]` section**, since PentAGI's own `search_path` startup parameter is ignored per point 2 above. `connect_query` runs on PgBouncer's own connection to PostgreSQL before any client statement, so it is not subject to the client-facing startup-parameter allowlist and works regardless of pool mode:
```ini
[databases]
pentagi_testing = host=pgvector port=5432 dbname=pentagidb pool_mode=session connect_query='SET search_path TO testing,public'
pentagi_acme = host=pgvector port=5432 dbname=pentagidb pool_mode=session connect_query='SET search_path TO acme,public'
```
Point each instance's `DATABASE_URL` at its own virtual database name (`pentagi_testing`, `pentagi_acme`, ...) rather than the shared `pentagidb` — PgBouncer pools per `(user, dbname)` pair, so distinct virtual names are what keeps the tenants' pools, and therefore their `connect_query`, apart.
(`track_extra_parameters = search_path` is PgBouncer's other mechanism for this, but it only works when PostgreSQL reports `search_path` changes back to the client, which requires PostgreSQL 18+ or Citus 12+ — not an option against the PostgreSQL 16/17 that ships in `docker-compose.yml`.)
#### Multi-tenant PostgreSQL through Supabase's Supavisor pooler (`DATABASE_SEARCH_PATH_VIA_OPTIONS`)
Supabase's shared/self-hosted pooler (Supavisor) is not PgBouncer and none of its `[databases]`/`connect_query` configuration exists for it, so the PgBouncer recipe above does not apply. Reports on whether Supavisor forwards a tenant's `search_path` at all are inconsistent — see [supabase/supavisor#206](https://github.com/supabase/supavisor/issues/206) — and depend on the Supavisor version (a parsing fix landed in [PR #768](https://github.com/supabase/supavisor/pull/768)).
If bypassing the pooler entirely (connecting straight to the underlying PostgreSQL, or to a Supabase project's "Direct connection"/IPv4-add-on string) is not an option, set:
```
DATABASE_SEARCH_PATH_VIA_OPTIONS=true
```
This sends the tenant's search_path as `options=--search_path=<tenant>,<DATABASE_EXTENSIONS_SCHEMA>` instead of a bare `search_path=` parameter. Some poolers forward the `options` startup parameter through to the real backend while silently dropping an unrecognized bare `search_path` — this is exactly the workaround reported to work against some Supavisor versions. **It is not guaranteed** — verify it actually took effect by checking that the app starts (`verifySearchPath` fails fast with a clear error if it did not) rather than assuming success from the flag alone.
This flag changes nothing for a direct PostgreSQL connection or a PgBouncer setup already following the recipe above; both accept `search_path` and `options` equally, so there is no reason to enable it outside a Supavisor-fronted deployment.
### Usage Details
- **DatabaseURL**: This is a critical setting used throughout the application for all database connections. It is used to:

File diff suppressed because it is too large Load Diff

View File

@@ -18,10 +18,9 @@ import (
type Config struct {
// === Core System Configuration ===
DatabaseURL string `env:"DATABASE_URL" envDefault:"postgres://pentagiuser:pentagipass@pgvector:5432/pentagidb?sslmode=disable"`
Debug bool `env:"DEBUG" envDefault:"false"`
DataDir string `env:"DATA_DIR" envDefault:"./data"`
AskUser bool `env:"ASK_USER" envDefault:"false"`
Debug bool `env:"DEBUG" envDefault:"false"`
DataDir string `env:"DATA_DIR" envDefault:"./data"`
AskUser bool `env:"ASK_USER" envDefault:"false"`
// TenantID namespaces every externally-visible artifact this instance creates
// (PostgreSQL schema, docker object names, Graphiti group ids, telemetry identity)
@@ -269,11 +268,20 @@ type Config struct {
// === Agent Planning Phase Configuration ===
AgentPlanningStepEnabled bool `env:"AGENT_PLANNING_STEP_ENABLED" envDefault:"false"`
// === Database Configuration ===
DatabaseURL string `env:"DATABASE_URL" envDefault:"postgres://pentagiuser:pentagipass@pgvector:5432/pentagidb?sslmode=disable"`
// === Database Connection Pool Sizing ===
DBMaxOpenConns int `env:"DATABASE_MAX_OPEN_CONNS" envDefault:"25"`
DBMaxIdleConns int `env:"DATABASE_MAX_IDLE_CONNS" envDefault:"5"`
DBVectorMaxConns int `env:"DATABASE_VECTOR_MAX_CONNS" envDefault:"10"`
// DatabaseExtensionsSchema/DatabaseSearchPathViaOptions only matter with
// TenantID set; see backend/docs/config.md -> "Multi-Instance Deployment
// (TENANT_ID)" for what they do and when to override them.
DatabaseExtensionsSchema string `env:"DATABASE_EXTENSIONS_SCHEMA" envDefault:""`
DatabaseSearchPathViaOptions bool `env:"DATABASE_SEARCH_PATH_VIA_OPTIONS" envDefault:"false"`
// PgxPool is the shared pgxpool.Pool for all pgvector stores. Populated by
// main after pool creation; NOT sourced from environment variables.
PgxPool *pgxpool.Pool `env:"-"`

View File

@@ -149,6 +149,16 @@ func (c *Config) SchemaName() string {
return c.TenantID
}
// ExtensionSchema returns the schema every tenant's search_path must include for
// shared extensions to resolve. See DATABASE_EXTENSIONS_SCHEMA in
// backend/docs/config.md for details; defaults to "public".
func (c *Config) ExtensionSchema() string {
if c == nil || c.DatabaseExtensionsSchema == "" {
return "public"
}
return c.DatabaseExtensionsSchema
}
// AuthSalt returns the effective salt for cookie and JWT key derivation. Mixing
// the tenant in makes one instance's session cookies and API tokens
// cryptographically invalid on another even when COOKIE_SIGNING_SALT is shared

View File

@@ -1,4 +1,4 @@
package main
package database
import (
"context"
@@ -20,11 +20,7 @@ import (
// would then fail with an opaque "type vector does not exist".
var requiredExtensions = []string{"vector", "pg_trgm"}
// sharedExtensionSchema is where extensions must live so that every tenant's
// search_path can reach them.
const sharedExtensionSchema = "public"
// ensureTenantSchema prepares this instance's PostgreSQL namespace and rewrites
// EnsureTenantSchema prepares this instance's PostgreSQL namespace and rewrites
// cfg.DatabaseURL so that every downstream consumer — sqlc, gorm, goose and the
// pgxpool backing the langchaingo vector store — resolves unqualified
// identifiers inside it.
@@ -32,12 +28,13 @@ const sharedExtensionSchema = "public"
// It is a strict no-op when TENANT_ID is empty: the DSN is left untouched and
// everything keeps resolving through the default "public" search path exactly
// as before.
func ensureTenantSchema(ctx context.Context, cfg *config.Config) error {
func EnsureTenantSchema(ctx context.Context, cfg *config.Config) error {
if !cfg.HasTenant() {
return nil
}
schema := cfg.SchemaName()
extSchema := cfg.ExtensionSchema()
// Short-lived bootstrap connection on the ORIGINAL DSN. Opening it before the
// search_path rewrite means CREATE EXTENSION resolves against the default
@@ -54,7 +51,7 @@ func ensureTenantSchema(ctx context.Context, cfg *config.Config) error {
// Serialize concurrent first boots so two instances cannot race on schema and
// extension creation in the shared catalog.
if err := withAdvisoryLock(ctx, db, "pentagi-tenant-bootstrap", func(conn *sql.Conn) error {
if err := WithAdvisoryLock(ctx, db, "pentagi-tenant-bootstrap", func(conn *sql.Conn) error {
// QuoteIdentifier is belt-and-braces: ValidateTenantID already restricts
// the character set, but this keeps the statement safe if that ever relaxes.
if _, err := conn.ExecContext(ctx,
@@ -64,7 +61,7 @@ func ensureTenantSchema(ctx context.Context, cfg *config.Config) error {
}
for _, ext := range requiredExtensions {
if err := ensureSharedExtension(ctx, conn, ext); err != nil {
if err := ensureSharedExtension(ctx, conn, ext, extSchema); err != nil {
return err
}
}
@@ -75,22 +72,37 @@ func ensureTenantSchema(ctx context.Context, cfg *config.Config) error {
}
// Rewrite the DSN once; every consumer reads cfg.DatabaseURL afterwards.
// Both lib/pq and pgx forward search_path as a libpq runtime parameter, so a
// single URL change reaches every connection in every pool.
rewritten, err := withSearchPath(cfg.DatabaseURL, schema+","+sharedExtensionSchema)
return RewriteDatabaseURLForTenant(cfg)
}
// RewriteDatabaseURLForTenant appends the tenant search_path to cfg.DatabaseURL.
// Unlike EnsureTenantSchema it does not touch the catalog — use it for tools that
// only need to read or write tenant data (e.g. the installer's password reset).
// It is a no-op when TENANT_ID is empty.
func RewriteDatabaseURLForTenant(cfg *config.Config) error {
if !cfg.HasTenant() {
return nil
}
rewritten, err := withSearchPath(
cfg.DatabaseURL,
cfg.SchemaName()+","+cfg.ExtensionSchema(),
cfg.DatabaseSearchPathViaOptions,
)
if err != nil {
return err
}
cfg.DatabaseURL = rewritten
return nil
}
// ensureSharedExtension guarantees that ext exists and is reachable from every
// tenant's search_path. It checks before creating so that a database whose
// extensions were pre-installed by an administrator works without the
// application needing CREATE privileges.
func ensureSharedExtension(ctx context.Context, conn *sql.Conn, ext string) error {
// ensureSharedExtension guarantees that ext exists in sharedSchema and is
// therefore reachable from every tenant's search_path. It checks before
// creating so that a database whose extensions were pre-installed by an
// administrator (or by convention — see DATABASE_EXTENSIONS_SCHEMA in
// backend/docs/config.md) works without the application needing CREATE
// privileges.
func ensureSharedExtension(ctx context.Context, conn *sql.Conn, ext, sharedSchema string) error {
schema, err := extensionSchema(ctx, conn, ext)
switch {
case err != nil:
@@ -100,24 +112,25 @@ func ensureSharedExtension(ctx context.Context, conn *sql.Conn, ext string) erro
// Not installed yet — create it explicitly in the shared schema.
if _, err := conn.ExecContext(ctx, fmt.Sprintf(
"CREATE EXTENSION IF NOT EXISTS %s SCHEMA %s",
pq.QuoteIdentifier(ext), pq.QuoteIdentifier(sharedExtensionSchema),
pq.QuoteIdentifier(ext), pq.QuoteIdentifier(sharedSchema),
)); err != nil {
return fmt.Errorf(
"failed to create extension %q in schema %q (a privileged user must run "+
"CREATE EXTENSION %s SCHEMA %s once): %w",
ext, sharedExtensionSchema, ext, sharedExtensionSchema, err,
ext, sharedSchema, ext, sharedSchema, err,
)
}
return nil
case schema != sharedExtensionSchema:
case schema != sharedSchema:
// Installed, but somewhere this tenant's search_path will not reach. Fail
// with an actionable message rather than letting migrations die on a
// confusing "type does not exist".
return fmt.Errorf(
"extension %q is installed in schema %q, but multi-tenant mode requires it in %q "+
"so every tenant can reach it; run: ALTER EXTENSION %s SET SCHEMA %s",
ext, schema, sharedExtensionSchema, ext, sharedExtensionSchema,
"so every tenant can reach it; either run ALTER EXTENSION %s SET SCHEMA %s, "+
"or set DATABASE_EXTENSIONS_SCHEMA=%s to match where it already lives",
ext, schema, sharedSchema, ext, sharedSchema, schema,
)
default:
@@ -145,10 +158,10 @@ func extensionSchema(ctx context.Context, conn *sql.Conn, ext string) (string, e
}
}
// verifySearchPath asserts that connections really do resolve into the expected
// VerifySearchPath asserts that connections really do resolve into the expected
// schema. A typo in the DSN would otherwise route a tenant silently onto public,
// where every tenant would share one dataset — a quiet, catastrophic failure.
func verifySearchPath(ctx context.Context, db *sql.DB, cfg *config.Config) error {
func VerifySearchPath(ctx context.Context, db *sql.DB, cfg *config.Config) error {
if !cfg.HasTenant() {
return nil
}
@@ -168,21 +181,21 @@ func verifySearchPath(ctx context.Context, db *sql.DB, cfg *config.Config) error
return nil
}
// runMigrations applies pending migrations while holding an advisory lock, so
// RunMigrations applies pending migrations while holding an advisory lock, so
// that two instances booting simultaneously cannot execute the same migration
// set concurrently. Without a tenant the lock is still taken, which also fixes
// the pre-existing race between two single-instance deployments sharing a
// database.
func runMigrations(ctx context.Context, db *sql.DB, cfg *config.Config, up func(*sql.DB) error) error {
return withAdvisoryLock(ctx, db, "pentagi-migrations-"+cfg.SchemaName(), func(*sql.Conn) error {
func RunMigrations(ctx context.Context, db *sql.DB, cfg *config.Config, up func(*sql.DB) error) error {
return WithAdvisoryLock(ctx, db, "pentagi-migrations-"+cfg.SchemaName(), func(*sql.Conn) error {
return up(db)
})
}
// withAdvisoryLock runs fn while holding a PostgreSQL session-level advisory
// WithAdvisoryLock runs fn while holding a PostgreSQL session-level advisory
// lock derived from key. The lock is taken on a dedicated connection because
// advisory locks are session-scoped and *sql.DB is a pool.
func withAdvisoryLock(ctx context.Context, db *sql.DB, key string, fn func(*sql.Conn) error) error {
func WithAdvisoryLock(ctx context.Context, db *sql.DB, key string, fn func(*sql.Conn) error) error {
// crc32 into the signed 32-bit space keeps the key stable and collision-free
// enough for the two distinct locks this application takes.
lockID := int64(int32(crc32.ChecksumIEEE([]byte(key))))
@@ -204,17 +217,25 @@ func withAdvisoryLock(ctx context.Context, db *sql.DB, key string, fn func(*sql.
return fn(conn)
}
// withSearchPath returns dsn with the search_path runtime parameter set. It
// supports both URL-style DSNs (the shipped default) and libpq keyword strings.
func withSearchPath(dsn, searchPath string) (string, error) {
// withSearchPath returns dsn with the tenant's search_path applied as a
// PostgreSQL startup parameter (or, with viaOptions, wrapped as
// options=--search_path=<value> for poolers that need it — see
// DATABASE_SEARCH_PATH_VIA_OPTIONS in backend/docs/config.md). Supports both
// URL-style DSNs and libpq keyword strings.
func withSearchPath(dsn, searchPath string, viaOptions bool) (string, error) {
key, value := "search_path", searchPath
if viaOptions {
key, value = "options", "--search_path="+searchPath
}
u, err := url.Parse(dsn)
if err != nil || u.Scheme == "" {
// Not a URL — fall back to libpq keyword/value syntax.
return fmt.Sprintf("%s search_path=%s", dsn, searchPath), nil
return fmt.Sprintf("%s %s=%s", dsn, key, value), nil
}
q := u.Query()
q.Set("search_path", searchPath)
q.Set(key, value)
u.RawQuery = q.Encode()
return u.String(), nil

View File

@@ -140,6 +140,8 @@ services:
- OAUTH_GITHUB_CLIENT_ID=${OAUTH_GITHUB_CLIENT_ID:-}
- OAUTH_GITHUB_CLIENT_SECRET=${OAUTH_GITHUB_CLIENT_SECRET:-}
- DATABASE_URL=postgres://${PENTAGI_POSTGRES_USER:-postgres}:${PENTAGI_POSTGRES_PASSWORD:-postgres}@pgvector:5432/${PENTAGI_POSTGRES_DB:-pentagidb}?sslmode=disable
- DATABASE_EXTENSIONS_SCHEMA=${DATABASE_EXTENSIONS_SCHEMA:-}
- DATABASE_SEARCH_PATH_VIA_OPTIONS=${DATABASE_SEARCH_PATH_VIA_OPTIONS:-}
- DATABASE_MAX_OPEN_CONNS=${DATABASE_MAX_OPEN_CONNS:-}
- DATABASE_MAX_IDLE_CONNS=${DATABASE_MAX_IDLE_CONNS:-}
- DATABASE_VECTOR_MAX_CONNS=${DATABASE_VECTOR_MAX_CONNS:-}