mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-09-03 07:25:50 +08:00
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:
@@ -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
4
.vscode/launch.json
vendored
@@ -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",
|
||||
|
||||
18
README.md
18
README.md
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:"-"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:-}
|
||||
|
||||
Reference in New Issue
Block a user