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.
145 lines
4.3 KiB
Python
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
|