Files
fengqun/swarm_minimal/config.py
T
gongzhiyong 111be3e435 Promote the minimal swarm prototype to the repository root
The standalone prototype should be the root-level project shape for fengqun while preserving the existing planning documents already at the root. This keeps README, examples, tests, and the Python package directly discoverable without deleting the prior docs.

Constraint: User clarified that swarm-minimal is the repository root, but other existing root files must remain.
Rejected: Deleting existing root docs | They are part of the fengqun repository context and were explicitly protected.
Confidence: high
Scope-risk: narrow
Directive: Keep secrets in ignored .env only; do not commit live credentials.
Tested: python3 -B -m unittest discover -s tests; git diff --check; secret-pattern scan showed only placeholders/test values/task-id false positives.
Not-tested: Remote web UI rendering after push.
2026-05-16 13:32:11 +08:00

145 lines
4.3 KiB
Python

"""Configuration loaded from environment variables.
Real credentials must stay outside the repository. This module reads them from
the process environment and only exposes redacted summaries for logs.
"""
from __future__ import annotations
from dataclasses import dataclass
import os
class MissingConfigError(RuntimeError):
pass
@dataclass(frozen=True)
class PostgresConfig:
host: str
user: str
port: int
database: str
password: str
@dataclass(frozen=True)
class RedisConfig:
host: str
port: int
password: str
ssl: bool
@dataclass(frozen=True)
class BlobConfig:
connection_string: str
container: str
@dataclass(frozen=True)
class SwarmConfig:
postgres: PostgresConfig
redis: RedisConfig
blob: BlobConfig
@classmethod
def from_env(cls) -> "SwarmConfig":
missing = [
name
for name in (
"PGHOST",
"PGUSER",
"PGDATABASE",
"PGPASSWORD",
"AZURE_STORAGE_CONNECTION_STRING",
)
if not os.environ.get(name)
]
redis_conn = os.environ.get("SWARM_REDIS_CONNECTION_STRING")
redis_host = os.environ.get("REDIS_HOST")
redis_password = os.environ.get("REDIS_PASSWORD")
if not redis_conn and not (redis_host and redis_password):
missing.append("SWARM_REDIS_CONNECTION_STRING or REDIS_HOST/REDIS_PASSWORD")
if missing:
raise MissingConfigError("missing required environment variables: " + ", ".join(missing))
return cls(
postgres=PostgresConfig(
host=os.environ["PGHOST"],
user=os.environ["PGUSER"],
port=int(os.environ.get("PGPORT", "5432")),
database=os.environ["PGDATABASE"],
password=os.environ["PGPASSWORD"],
),
redis=parse_redis_config(redis_conn)
if redis_conn
else RedisConfig(
host=os.environ["REDIS_HOST"],
port=int(os.environ.get("REDIS_PORT", "6380")),
password=os.environ["REDIS_PASSWORD"],
ssl=os.environ.get("REDIS_SSL", "true").lower() in {"1", "true", "yes"},
),
blob=BlobConfig(
connection_string=os.environ["AZURE_STORAGE_CONNECTION_STRING"],
container=os.environ.get("SWARM_BLOB_CONTAINER", "swarm-artifacts"),
),
)
def redacted_summary(self) -> dict[str, object]:
account_name = _extract_connection_value(self.blob.connection_string, "AccountName")
return {
"postgres": {
"host": self.postgres.host,
"port": self.postgres.port,
"database": self.postgres.database,
"user": self.postgres.user,
"password": "<redacted>",
},
"redis": {
"host": self.redis.host,
"port": self.redis.port,
"ssl": self.redis.ssl,
"password": "<redacted>",
},
"blob": {
"account_name": account_name or "<unknown>",
"container": self.blob.container,
"account_key": "<redacted>",
},
}
def parse_redis_config(value: str) -> RedisConfig:
parts = [part.strip() for part in value.split(",") if part.strip()]
if not parts or ":" not in parts[0]:
raise ValueError("redis connection string must start with host:port")
host, port_text = parts[0].rsplit(":", 1)
fields: dict[str, str] = {}
for part in parts[1:]:
if "=" in part:
key, field_value = part.split("=", 1)
fields[key.strip().lower()] = field_value.strip()
password = fields.get("password")
if not password:
raise ValueError("redis connection string must include password")
return RedisConfig(
host=host,
port=int(port_text),
password=password,
ssl=fields.get("ssl", "true").lower() in {"1", "true", "yes"},
)
def _extract_connection_value(connection_string: str, key: str) -> str | None:
prefix = f"{key}="
for part in connection_string.split(";"):
if part.startswith(prefix):
return part[len(prefix) :]
return None