coordination-engine/src/coordination_engine/config.py

169 lines
5.4 KiB
Python
Raw Normal View History

import hashlib
import json
import math
import os
from dataclasses import asdict, dataclass, field
from pathlib import Path
from urllib.parse import urlsplit
import tomllib
@dataclass
class Config:
state_dir: Path = field(
default_factory=lambda: Path(
os.environ.get(
"COORDINATION_STATE_DIR",
str(
Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state"))
/ "coordination-engine"
),
)
)
)
socket: Path = field(
default_factory=lambda: Path(
os.environ.get(
"COORDINATION_SOCKET",
str(
Path(
os.environ.get(
"XDG_RUNTIME_DIR", f"/tmp/coordination-{os.getuid()}"
)
)
/ "coordination-engine.sock"
),
)
)
)
tamq_socket: Path = field(
default_factory=lambda: Path(
os.environ.get(
"TAMQ_SOCKET",
str(Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "tamq.sock"),
)
)
)
api_base: str = field(
default_factory=lambda: os.environ.get(
"STATEHUB_API_BASE", "http://127.0.0.1:8000"
)
)
repos: list[str] = field(default_factory=list)
endpoints: dict[str, str] = field(default_factory=dict)
poll_interval: float = 15
lease_seconds: float = 30
renew_interval: float = 10
timeout: float = 5
busy_timeout: float = 5
max_attempts: int = 4
retry_backoff: list[float] = field(default_factory=lambda: [5, 15, 60, 300])
policy_profile: str = "default"
# Only these classes may be requested automatically. Unknown classes stop.
allow: list[str] = field(
default_factory=lambda: [
"repo_inspect",
"repo_edit",
"local_checks",
"state_updates",
"tmux_wake",
]
)
def validate(self):
for key in (
"poll_interval",
"lease_seconds",
"renew_interval",
"timeout",
"busy_timeout",
):
value = getattr(self, key)
if (
isinstance(value, bool)
or not isinstance(value, (float, int))
or not math.isfinite(value)
or value <= 0
):
raise ValueError(f"invalid {key}")
if self.renew_interval >= self.lease_seconds:
raise ValueError("renew_interval must be shorter than lease_seconds")
if type(self.max_attempts) is not int or not 0 <= self.max_attempts <= 9:
raise ValueError("max_attempts must be 0..9")
if not self.retry_backoff or any(
type(v) not in (int, float) or not math.isfinite(v) or v <= 0
for v in self.retry_backoff
):
raise ValueError("retry_backoff must contain positive finite delays")
if self.policy_profile != "default" or not set(self.allow) <= {
"repo_inspect",
"repo_edit",
"local_checks",
"state_updates",
"tmux_wake",
}:
raise ValueError("unsupported policy profile or action grant")
if not isinstance(self.repos, list) or any(
not isinstance(r, str)
or not r
or any(
c
not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._"
for c in r
)
for r in self.repos
):
raise ValueError("repos must contain exact repository slugs")
if not isinstance(self.endpoints, dict) or any(
k not in self.repos or not isinstance(v, str) or not v
for k, v in self.endpoints.items()
):
raise ValueError("endpoints must map configured repos to endpoint IDs")
url = urlsplit(self.api_base)
if (
url.scheme not in {"http", "https"}
or not url.hostname
or url.username
or url.password
or url.query
or url.fragment
):
raise ValueError("invalid State Hub URL; credentials are not configuration")
for name in ("state_dir", "socket", "tamq_socket"):
setattr(self, name, Path(getattr(self, name)).expanduser().absolute())
return self
@property
def version(self):
return hashlib.sha256(
json.dumps(asdict(self), default=str, sort_keys=True).encode()
).hexdigest()[:16]
@classmethod
def load(cls, path=None):
path = Path(
path
or os.environ.get(
"COORDINATION_CONFIG",
str(
Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
/ "coordination-engine/config.toml"
),
)
)
data = (
tomllib.loads(path.read_text()).get("coordination", {})
if path.exists()
else {}
)
for env, key in [
("COORDINATION_STATE_DIR", "state_dir"),
("COORDINATION_SOCKET", "socket"),
("TAMQ_SOCKET", "tamq_socket"),
("STATEHUB_API_BASE", "api_base"),
]:
if env in os.environ:
data[key] = os.environ[env]
return cls(**data).validate()