All checks were successful
CI/CD / detect-changes (pull_request) Successful in 21s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Successful in 2m58s
CI/CD / test-sqlite (pull_request) Successful in 3m2s
CI/CD / build-and-deploy (pull_request) Successful in 1m15s
PG-backed cluster registry for horizontal scaling — zero new infrastructure (no etcd/Consul/Redis). MVP convergence point. - node_registry UNLOGGED table (migration 013) - Self-registration, heartbeat tick (10s), stale sweep (30s), self-eviction (os.Exit on 0 rows → K8s restarts) - GET /api/v1/admin/cluster returns nodes with runtime stats - Health endpoints include node_id + cluster size/peers - cluster-dashboard admin surface with auto-refresh - 3-replica E2E test (docker-compose-e2e.yml + ci/e2e-cluster-test.sh) - chat + cluster-dashboard added to curated default bundle - 5 new tests (3 unit + 2 handler), all passing Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
243 lines
8.8 KiB
Go
243 lines
8.8 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/joho/godotenv"
|
|
)
|
|
|
|
// Config holds all application configuration.
|
|
type Config struct {
|
|
Port string
|
|
DatabaseURL string
|
|
DBDriver string // "postgres" (default) or "sqlite"
|
|
JWTSecret string
|
|
Environment string
|
|
BasePath string // URL path prefix (e.g. "/dev", "/test", or "" for root)
|
|
|
|
// Admin bootstrap (optional — set via K8s secret)
|
|
AdminUsername string
|
|
AdminPassword string
|
|
AdminEmail string
|
|
|
|
// Seed users (dev/test only) — CSV: "user:pass:role,user2:pass2:role2"
|
|
SeedUsers string
|
|
|
|
// API key encryption (required for v0.9.4+)
|
|
// Used to derive AES-256 key for global/team provider API keys.
|
|
// Personal keys use per-user UEK (derived from password).
|
|
EncryptionKey string
|
|
|
|
// File storage (v0.12.0+)
|
|
// STORAGE_BACKEND: "pvc" or "s3". Empty = auto-detect (pvc if path writable).
|
|
// STORAGE_PATH: mount point for PVC backend (default /data/storage).
|
|
StorageBackend string
|
|
StoragePath string
|
|
|
|
// S3-compatible storage (v0.12.0+)
|
|
// Works with AWS S3, MinIO, Ceph RGW, or any S3-compatible API.
|
|
S3Endpoint string // custom endpoint URL (required for MinIO/Ceph, optional for AWS)
|
|
S3Bucket string // bucket name (required when STORAGE_BACKEND=s3)
|
|
S3Region string // AWS region (default "us-east-1")
|
|
S3AccessKey string // access key ID
|
|
S3SecretKey string // secret access key
|
|
S3Prefix string // optional key prefix within bucket (e.g. "switchboard/")
|
|
S3ForcePathStyle bool // use path-style URLs (required for MinIO, most self-hosted)
|
|
|
|
// Structured logging (v0.33.0)
|
|
// LOG_FORMAT: "text" (default, backward-compatible) or "json" (structured).
|
|
// LOG_LEVEL: "debug", "info" (default), "warn", "error".
|
|
LogFormat string
|
|
LogLevel string
|
|
|
|
// Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc"
|
|
AuthMode string
|
|
|
|
// mTLS (v0.24.1)
|
|
// Headers injected by TLS-terminating reverse proxy.
|
|
MTLSHeaderDN string // default "X-SSL-Client-DN"
|
|
MTLSHeaderVerify string // default "X-SSL-Client-Verify"
|
|
MTLSHeaderFingerprint string // default "X-SSL-Client-Fingerprint"
|
|
MTLSAutoActivate bool // auto-activate new users (default true)
|
|
MTLSDefaultTeam string // team ID for auto-provisioned users (optional)
|
|
|
|
// Bundled packages (v0.3.8, curated v0.5.4)
|
|
// SKIP_BUNDLED_PACKAGES: set true to disable auto-install of bundled packages on first run.
|
|
// BUNDLED_PACKAGES_DIR: directory containing pre-built .pkg archives (default /app/bundled-packages).
|
|
// BUNDLED_PACKAGES: controls which bundled packages are auto-installed:
|
|
// empty → curated default set (notes, chat-core, workflow-chat, dashboard, demo workflows)
|
|
// "*" → install all .pkg archives found in the directory
|
|
// "a,b" → comma-separated allowlist of specific package IDs
|
|
SkipBundledPackages bool
|
|
BundledPackagesDir string
|
|
BundledPackages string
|
|
|
|
// OIDC (v0.24.1)
|
|
OIDCIssuerURL string // e.g. "https://keycloak.corp/realms/switchboard"
|
|
OIDCExternalIssuerURL string // OIDC_EXTERNAL_ISSUER_URL
|
|
OIDCClientID string
|
|
OIDCClientSecret string
|
|
OIDCRedirectURL string // e.g. "https://switchboard.corp/api/v1/auth/oidc/callback"
|
|
OIDCAutoActivate bool // auto-activate new users (default true)
|
|
OIDCDefaultTeam string // team ID for auto-provisioned users (optional)
|
|
OIDCRolesClaim string // JWT claim for role mapping (default "realm_access.roles")
|
|
OIDCGroupsClaim string // JWT claim for group sync (default "groups")
|
|
OIDCAdminRole string // IdP role value that maps to admin (default "admin")
|
|
|
|
// Cluster registry (v0.6.0)
|
|
// PG-backed node registry for horizontal scaling. No-op on SQLite.
|
|
// CLUSTER_NODE_ID: override for deterministic identity (default: hostname-PID).
|
|
// CLUSTER_HEARTBEAT_INTERVAL: tick frequency (default "10s").
|
|
// CLUSTER_STALE_THRESHOLD: 3x heartbeat = stale (default "30s").
|
|
// CLUSTER_ENDPOINT: advertised address for peer mesh (Phase 2, auto-detect).
|
|
ClusterNodeID string
|
|
ClusterHeartbeatInterval time.Duration
|
|
ClusterStaleThreshold time.Duration
|
|
ClusterEndpoint string
|
|
}
|
|
|
|
// Load reads configuration from environment variables.
|
|
// A .env file is loaded if present but not required.
|
|
func Load() *Config {
|
|
// Best-effort .env load (not required in production)
|
|
_ = godotenv.Load()
|
|
|
|
return &Config{
|
|
Port: getEnv("PORT", "8080"),
|
|
DatabaseURL: resolveDatabaseURL(),
|
|
DBDriver: getEnv("DB_DRIVER", ""),
|
|
JWTSecret: getEnv("JWT_SECRET", "dev-secret-change-me"),
|
|
Environment: getEnv("ENVIRONMENT", "development"),
|
|
BasePath: sanitizeBasePath(getEnv("BASE_PATH", "")),
|
|
AdminUsername: getEnv("SWITCHBOARD_ADMIN_USERNAME", ""),
|
|
AdminPassword: getEnv("SWITCHBOARD_ADMIN_PASSWORD", ""),
|
|
AdminEmail: getEnv("SWITCHBOARD_ADMIN_EMAIL", ""),
|
|
SeedUsers: getEnv("SEED_USERS", ""),
|
|
EncryptionKey: getEnv("ENCRYPTION_KEY", ""),
|
|
|
|
StorageBackend: getEnv("STORAGE_BACKEND", ""),
|
|
StoragePath: getEnv("STORAGE_PATH", "/data/storage"),
|
|
S3Endpoint: getEnv("S3_ENDPOINT", ""),
|
|
S3Bucket: getEnv("S3_BUCKET", ""),
|
|
S3Region: getEnv("S3_REGION", "us-east-1"),
|
|
S3AccessKey: getEnv("S3_ACCESS_KEY", ""),
|
|
S3SecretKey: getEnv("S3_SECRET_KEY", ""),
|
|
S3Prefix: getEnv("S3_PREFIX", ""),
|
|
S3ForcePathStyle: getEnv("S3_FORCE_PATH_STYLE", "true") == "true",
|
|
|
|
SkipBundledPackages: getEnvBool("SKIP_BUNDLED_PACKAGES", false),
|
|
BundledPackagesDir: getEnv("BUNDLED_PACKAGES_DIR", "/app/bundled-packages"),
|
|
BundledPackages: getEnv("BUNDLED_PACKAGES", ""),
|
|
|
|
LogFormat: getEnv("LOG_FORMAT", "text"),
|
|
LogLevel: getEnv("LOG_LEVEL", "info"),
|
|
|
|
AuthMode: getEnv("AUTH_MODE", "builtin"),
|
|
|
|
// mTLS
|
|
MTLSHeaderDN: getEnv("MTLS_HEADER_DN", "X-SSL-Client-DN"),
|
|
MTLSHeaderVerify: getEnv("MTLS_HEADER_VERIFY", "X-SSL-Client-Verify"),
|
|
MTLSHeaderFingerprint: getEnv("MTLS_HEADER_FINGERPRINT", "X-SSL-Client-Fingerprint"),
|
|
MTLSAutoActivate: getEnvBool("MTLS_AUTO_ACTIVATE", true),
|
|
MTLSDefaultTeam: getEnv("MTLS_DEFAULT_TEAM", ""),
|
|
|
|
// OIDC
|
|
OIDCIssuerURL: getEnv("OIDC_ISSUER_URL", ""),
|
|
OIDCExternalIssuerURL: getEnv("OIDC_EXTERNAL_ISSUER_URL", ""),
|
|
OIDCClientID: getEnv("OIDC_CLIENT_ID", ""),
|
|
OIDCClientSecret: getEnv("OIDC_CLIENT_SECRET", ""),
|
|
OIDCRedirectURL: getEnv("OIDC_REDIRECT_URL", ""),
|
|
OIDCAutoActivate: getEnvBool("OIDC_AUTO_ACTIVATE", true),
|
|
OIDCDefaultTeam: getEnv("OIDC_DEFAULT_TEAM", ""),
|
|
OIDCRolesClaim: getEnv("OIDC_ROLES_CLAIM", "realm_access.roles"),
|
|
OIDCGroupsClaim: getEnv("OIDC_GROUPS_CLAIM", "groups"),
|
|
OIDCAdminRole: getEnv("OIDC_ADMIN_ROLE", "admin"),
|
|
|
|
// Cluster
|
|
ClusterNodeID: getEnv("CLUSTER_NODE_ID", ""),
|
|
ClusterHeartbeatInterval: getEnvDuration("CLUSTER_HEARTBEAT_INTERVAL", 10*time.Second),
|
|
ClusterStaleThreshold: getEnvDuration("CLUSTER_STALE_THRESHOLD", 30*time.Second),
|
|
ClusterEndpoint: getEnv("CLUSTER_ENDPOINT", ""),
|
|
}
|
|
}
|
|
|
|
// resolveDatabaseURL returns the database connection string.
|
|
//
|
|
// Resolution order:
|
|
// 1. DATABASE_URL — explicit DSN (K8s, .env, manual)
|
|
// 2. POSTGRES_* vars — assembled from individual env vars:
|
|
// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB
|
|
// (set by K8s manifest and docker-compose)
|
|
// 3. Empty string — backend runs without database
|
|
func resolveDatabaseURL() string {
|
|
if dsn := os.Getenv("DATABASE_URL"); dsn != "" {
|
|
return dsn
|
|
}
|
|
// Assemble from individual vars (K8s sets POSTGRES_*, docker-compose may too)
|
|
host := getEnv("POSTGRES_HOST", "")
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
port := getEnv("POSTGRES_PORT", "5432")
|
|
user := getEnv("POSTGRES_USER", "")
|
|
pass := getEnv("POSTGRES_PASSWORD", "")
|
|
db := getEnv("POSTGRES_DB", "switchboard_core")
|
|
ssl := getEnv("POSTGRES_SSLMODE", "disable")
|
|
return fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s", user, pass, host, port, db, ssl)
|
|
}
|
|
|
|
// sanitizeBasePath ensures the path starts with / and doesn't end with /.
|
|
// Empty string means root (no prefix).
|
|
func sanitizeBasePath(p string) string {
|
|
if p == "" || p == "/" {
|
|
return ""
|
|
}
|
|
// Ensure leading slash
|
|
if p[0] != '/' {
|
|
p = "/" + p
|
|
}
|
|
// Strip trailing slash
|
|
for len(p) > 1 && p[len(p)-1] == '/' {
|
|
p = p[:len(p)-1]
|
|
}
|
|
return p
|
|
}
|
|
|
|
func getEnv(key, fallback string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func getEnvInt(key string, fallback int) int {
|
|
if v := os.Getenv(key); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func getEnvDuration(key string, fallback time.Duration) time.Duration {
|
|
if v := os.Getenv(key); v != "" {
|
|
if d, err := time.ParseDuration(v); err == nil {
|
|
return d
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func getEnvBool(key string, fallback bool) bool {
|
|
if v := os.Getenv(key); v != "" {
|
|
b, err := strconv.ParseBool(v)
|
|
if err == nil {
|
|
return b
|
|
}
|
|
}
|
|
return fallback
|
|
}
|