Files
orchard/backend/app/config.py
2026-02-05 09:15:48 -06:00

254 lines
9.2 KiB
Python

from pydantic_settings import BaseSettings
from functools import lru_cache
from typing import Optional
import os
import re
class Settings(BaseSettings):
# Environment
env: str = "development" # "development" or "production"
# Server
server_host: str = "0.0.0.0"
server_port: int = 8080
# Database
database_host: str = "localhost"
database_port: int = 5432
database_user: str = "orchard"
database_password: str = ""
database_dbname: str = "orchard"
database_sslmode: str = "disable"
# Database connection pool settings
database_pool_size: int = 20 # Number of connections to keep open
database_max_overflow: int = 30 # Max additional connections beyond pool_size
database_pool_timeout: int = 30 # Seconds to wait for a connection from pool
database_pool_recycle: int = (
1800 # Recycle connections after this many seconds (30 min)
)
database_query_timeout: int = 30 # Query timeout in seconds (0 = no timeout)
# S3
s3_endpoint: str = ""
s3_region: str = "us-east-1"
s3_bucket: str = "orchard-artifacts"
s3_access_key_id: str = ""
s3_secret_access_key: str = ""
s3_use_path_style: bool = True
s3_verify_ssl: bool = True # Set to False for self-signed certs (dev only)
s3_connect_timeout: int = 10 # Connection timeout in seconds
s3_read_timeout: int = 60 # Read timeout in seconds
s3_max_retries: int = 3 # Max retry attempts for transient failures
# Upload settings
max_file_size: int = 10 * 1024 * 1024 * 1024 # 10GB default max file size
min_file_size: int = 1 # Minimum 1 byte (empty files rejected)
# Download settings
download_mode: str = "presigned" # "presigned", "redirect", or "proxy"
presigned_url_expiry: int = (
3600 # Presigned URL expiry in seconds (default: 1 hour)
)
pypi_download_mode: str = "redirect" # "redirect" (to S3) or "proxy" (stream through Orchard)
# HTTP Client pool settings
http_max_connections: int = 100 # Max connections per pool
http_max_keepalive: int = 20 # Keep-alive connections
http_connect_timeout: float = 30.0 # Connection timeout seconds
http_read_timeout: float = 60.0 # Read timeout seconds
http_worker_threads: int = 32 # Thread pool for blocking ops
# Redis cache settings
redis_host: str = "localhost"
redis_port: int = 6379
redis_db: int = 0
redis_password: Optional[str] = None
redis_enabled: bool = True # Set False to disable caching
# Cache TTL settings (seconds, 0 = no expiry)
cache_ttl_index: int = 300 # Package index pages: 5 min
cache_ttl_versions: int = 300 # Version listings: 5 min
cache_ttl_upstream: int = 3600 # Upstream source config: 1 hour
# Logging settings
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL
log_format: str = "auto" # "json", "standard", or "auto" (json in production)
# Initial admin user settings
admin_password: str = "" # Initial admin password (if empty, uses 'changeme123')
# Cache settings
cache_encryption_key: str = "" # Fernet key for encrypting upstream credentials (auto-generated if empty)
# Global cache settings override (None = use DB value, True/False = override DB)
cache_auto_create_system_projects: Optional[bool] = None # Override auto_create_system_projects
# PyPI Cache Worker settings
pypi_cache_workers: int = 5 # Number of concurrent cache workers
pypi_cache_max_depth: int = 10 # Maximum recursion depth for dependency caching
pypi_cache_max_attempts: int = 3 # Maximum retry attempts for failed cache tasks
# Auto-fetch configuration for dependency resolution
auto_fetch_dependencies: bool = False # Server default for auto_fetch parameter
auto_fetch_max_depth: int = 10 # Maximum fetch recursion depth to prevent runaway fetching
auto_fetch_timeout: int = 300 # Total timeout for auto-fetch resolution in seconds
# JWT Authentication settings (optional, for external identity providers)
jwt_enabled: bool = False # Enable JWT token validation
jwt_secret: str = "" # Secret key for HS256, or leave empty for RS256 with JWKS
jwt_algorithm: str = "HS256" # HS256 or RS256
jwt_issuer: str = "" # Expected issuer (iss claim), leave empty to skip validation
jwt_audience: str = "" # Expected audience (aud claim), leave empty to skip validation
jwt_jwks_url: str = "" # JWKS URL for RS256 (e.g., https://auth.example.com/.well-known/jwks.json)
jwt_username_claim: str = (
"sub" # JWT claim to use as username (sub, email, preferred_username, etc.)
)
@property
def database_url(self) -> str:
sslmode = f"?sslmode={self.database_sslmode}" if self.database_sslmode else ""
return f"postgresql://{self.database_user}:{self.database_password}@{self.database_host}:{self.database_port}/{self.database_dbname}{sslmode}"
@property
def is_development(self) -> bool:
return self.env.lower() == "development"
@property
def is_production(self) -> bool:
return self.env.lower() == "production"
@property
def PORT(self) -> int:
"""Alias for server_port for compatibility."""
return self.server_port
# Uppercase aliases for PyPI cache settings (for backward compatibility)
@property
def PYPI_CACHE_WORKERS(self) -> int:
return self.pypi_cache_workers
@property
def PYPI_CACHE_MAX_DEPTH(self) -> int:
return self.pypi_cache_max_depth
@property
def PYPI_CACHE_MAX_ATTEMPTS(self) -> int:
return self.pypi_cache_max_attempts
class Config:
env_prefix = "ORCHARD_"
case_sensitive = False
@lru_cache()
def get_settings() -> Settings:
return Settings()
class EnvUpstreamSource:
"""Represents an upstream source defined via environment variables."""
def __init__(
self,
name: str,
url: str,
source_type: str = "generic",
enabled: bool = True,
auth_type: str = "none",
username: Optional[str] = None,
password: Optional[str] = None,
priority: int = 100,
):
self.name = name
self.url = url
self.source_type = source_type
self.enabled = enabled
self.auth_type = auth_type
self.username = username
self.password = password
self.priority = priority
self.source = "env" # Mark as env-defined
def parse_upstream_sources_from_env() -> list[EnvUpstreamSource]:
"""
Parse upstream sources from environment variables.
Uses double underscore (__) as separator to allow source names with single underscores.
Pattern: ORCHARD_UPSTREAM__{NAME}__FIELD
Example:
ORCHARD_UPSTREAM__NPM_PRIVATE__URL=https://npm.corp.com
ORCHARD_UPSTREAM__NPM_PRIVATE__TYPE=npm
ORCHARD_UPSTREAM__NPM_PRIVATE__ENABLED=true
ORCHARD_UPSTREAM__NPM_PRIVATE__AUTH_TYPE=basic
ORCHARD_UPSTREAM__NPM_PRIVATE__USERNAME=reader
ORCHARD_UPSTREAM__NPM_PRIVATE__PASSWORD=secret
Returns:
List of EnvUpstreamSource objects parsed from environment variables.
"""
# Pattern: ORCHARD_UPSTREAM__{NAME}__{FIELD}
pattern = re.compile(r"^ORCHARD_UPSTREAM__([A-Z0-9_]+)__([A-Z_]+)$", re.IGNORECASE)
# Collect all env vars matching the pattern, grouped by source name
sources_data: dict[str, dict[str, str]] = {}
for key, value in os.environ.items():
match = pattern.match(key)
if match:
source_name = match.group(1).lower() # Normalize to lowercase
field = match.group(2).upper()
if source_name not in sources_data:
sources_data[source_name] = {}
sources_data[source_name][field] = value
# Build source objects from collected data
sources: list[EnvUpstreamSource] = []
for name, data in sources_data.items():
# URL is required
url = data.get("URL")
if not url:
continue # Skip sources without URL
# Parse boolean fields
def parse_bool(val: Optional[str], default: bool) -> bool:
if val is None:
return default
return val.lower() in ("true", "1", "yes", "on")
# Parse integer fields
def parse_int(val: Optional[str], default: int) -> int:
if val is None:
return default
try:
return int(val)
except ValueError:
return default
source = EnvUpstreamSource(
name=name.replace("_", "-"), # Convert underscores to hyphens for readability
url=url,
source_type=data.get("TYPE", "generic").lower(),
enabled=parse_bool(data.get("ENABLED"), True),
auth_type=data.get("AUTH_TYPE", "none").lower(),
username=data.get("USERNAME"),
password=data.get("PASSWORD"),
priority=parse_int(data.get("PRIORITY"), 100),
)
sources.append(source)
return sources
@lru_cache()
def get_env_upstream_sources() -> tuple[EnvUpstreamSource, ...]:
"""
Get cached list of upstream sources from environment variables.
Returns a tuple for hashability (required by lru_cache).
"""
return tuple(parse_upstream_sources_from_env())