Files
orchestrator/orchestrator/config.py
ARCHITECT ccd3868cd7 fix: handle None values in servers/agents config parsing
- Fix AttributeError when servers: or agents: is empty/None in config.yaml
- Use `or {}` pattern to safely handle None values
- Orchestrator CLI now starts correctly with minimal config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-24 08:51:47 +00:00

287 lines
8.7 KiB
Python

# orchestrator/config.py
"""
Configuración segura del orquestador.
Carga configuración desde:
1. Variables de entorno
2. Archivo .env
3. config.yaml
NUNCA hardcodea credenciales aquí.
"""
import os
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional, Any
def load_env():
"""Carga variables desde .env si existe."""
env_file = Path.cwd() / ".env"
if not env_file.exists():
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and value:
os.environ.setdefault(key, value)
# Cargar .env al importar
load_env()
def get_env(key: str, default: str = "") -> str:
"""Obtiene variable de entorno."""
return os.environ.get(key, default)
def get_env_bool(key: str, default: bool = False) -> bool:
"""Obtiene variable de entorno como booleano."""
val = os.environ.get(key, "").lower()
if val in ("true", "yes", "1"):
return True
if val in ("false", "no", "0"):
return False
return default
def get_env_list(key: str, default: list = None) -> list:
"""Obtiene variable de entorno como lista."""
val = os.environ.get(key, "")
if val:
return [x.strip() for x in val.split(",") if x.strip()]
return default or []
@dataclass
class ServerConfig:
"""Configuración de un servidor."""
name: str
host: str
user: str = "root"
key: str = ""
description: str = ""
def __post_init__(self):
if not self.key:
self.key = get_env("SSH_KEY_PATH", "~/.ssh/id_rsa")
@dataclass
class AgentConfig:
"""Configuración de un agente."""
name: str
role: str
provider: str = "claude"
model: str = "sonnet"
tools: list = field(default_factory=list)
servers: list = field(default_factory=list)
@dataclass
class Settings:
"""Configuración general."""
default_provider: str = "claude"
default_model: str = "sonnet"
timeout: float = 300.0
working_dir: str = "."
max_tool_iterations: int = 10
# Seguridad
ssh_strict_host_checking: bool = True
sandbox_paths: bool = True # Restringir paths al working_dir
allowed_commands: list = field(default_factory=list) # Whitelist de comandos
# Rate limiting
rate_limit_per_minute: int = 60
# Retry
max_retries: int = 3
retry_delay: float = 1.0
@dataclass
class GitConfig:
"""Configuración de Gitea/Git."""
url: str = ""
token_read: str = ""
token_write: str = ""
org: str = ""
def __post_init__(self):
self.url = self.url or get_env("GITEA_URL")
self.token_read = self.token_read or get_env("GITEA_TOKEN_READ")
self.token_write = self.token_write or get_env("GITEA_TOKEN_WRITE")
self.org = self.org or get_env("GITEA_ORG")
@property
def is_configured(self) -> bool:
return bool(self.url and self.token_read)
@dataclass
class R2Config:
"""Configuración de Cloudflare R2."""
endpoint: str = ""
access_key: str = ""
secret_key: str = ""
buckets: list = field(default_factory=list)
def __post_init__(self):
self.endpoint = self.endpoint or get_env("R2_ENDPOINT")
self.access_key = self.access_key or get_env("R2_ACCESS_KEY")
self.secret_key = self.secret_key or get_env("R2_SECRET_KEY")
self.buckets = self.buckets or get_env_list("R2_BUCKETS")
@property
def is_configured(self) -> bool:
return bool(self.endpoint and self.access_key and self.secret_key)
class Config:
"""Gestiona la configuración del orquestador."""
def __init__(self, config_path: Optional[str] = None):
self.config_path = self._find_config(config_path)
self.base_dir = self.config_path.parent if self.config_path else Path.cwd()
self._raw = self._load_yaml() if self.config_path else {}
# Parsear configuración
self.settings = self._parse_settings()
self.servers = self._parse_servers()
self.agents = self._parse_agents()
self.tasks = self._raw.get("tasks", {})
# Servicios externos (desde .env)
self.git = GitConfig()
self.r2 = R2Config()
# Rutas
self.logs_dir = self.base_dir / "logs"
self.outputs_dir = self.base_dir / "outputs"
# Crear directorios
self.logs_dir.mkdir(exist_ok=True)
self.outputs_dir.mkdir(exist_ok=True)
def _find_config(self, config_path: Optional[str]) -> Optional[Path]:
"""Encuentra el archivo de configuración."""
if config_path:
path = Path(config_path)
if path.exists():
return path
raise FileNotFoundError(f"Config no encontrado: {config_path}")
search_paths = [
Path.cwd() / "config.yaml",
Path.cwd() / "config.yml",
Path(__file__).parent.parent / "config.yaml",
]
for path in search_paths:
if path.exists():
return path
return None # Sin config, usar defaults
def _load_yaml(self) -> dict:
"""Carga el archivo YAML."""
if not self.config_path:
return {}
try:
import yaml
with open(self.config_path) as f:
return yaml.safe_load(f) or {}
except ImportError:
print("AVISO: PyYAML no instalado. pip install pyyaml")
return {}
def _parse_settings(self) -> Settings:
"""Parsea la sección settings."""
raw = self._raw.get("settings", {})
return Settings(
default_provider=raw.get("default_provider", "claude"),
default_model=raw.get("default_model", "sonnet"),
timeout=float(raw.get("timeout", 300)),
working_dir=raw.get("working_dir", "."),
max_tool_iterations=int(raw.get("max_tool_iterations", 10)),
ssh_strict_host_checking=raw.get("ssh_strict_host_checking",
get_env_bool("SSH_KNOWN_HOSTS_CHECK", True)),
sandbox_paths=raw.get("sandbox_paths", True),
allowed_commands=raw.get("allowed_commands", []),
rate_limit_per_minute=int(raw.get("rate_limit_per_minute", 60)),
max_retries=int(raw.get("max_retries", 3)),
retry_delay=float(raw.get("retry_delay", 1.0)),
)
def _parse_servers(self) -> dict[str, ServerConfig]:
"""Parsea la sección servers."""
servers = {}
raw_servers = self._raw.get("servers") or {}
for name, data in raw_servers.items():
if data:
servers[name] = ServerConfig(
name=name,
host=data.get("host", ""),
user=data.get("user", "root"),
key=data.get("key", get_env("SSH_KEY_PATH", "~/.ssh/id_rsa")),
description=data.get("description", ""),
)
return servers
def _parse_agents(self) -> dict[str, AgentConfig]:
"""Parsea la sección agents."""
agents = {}
raw_agents = self._raw.get("agents") or {}
for name, data in raw_agents.items():
if data:
agents[name] = AgentConfig(
name=name,
role=data.get("role", ""),
provider=data.get("provider", self.settings.default_provider),
model=data.get("model", self.settings.default_model),
tools=data.get("tools", []),
servers=data.get("servers", []),
)
return agents
def get_agent(self, name: str) -> Optional[AgentConfig]:
return self.agents.get(name)
def get_server(self, name: str) -> Optional[ServerConfig]:
return self.servers.get(name)
def list_agents(self) -> list[str]:
return list(self.agents.keys())
def list_servers(self) -> list[str]:
return list(self.servers.keys())
# Instancia global
_config: Optional[Config] = None
def get_config(config_path: Optional[str] = None) -> Config:
"""Obtiene la configuración global."""
global _config
if _config is None:
_config = Config(config_path)
return _config
def reload_config(config_path: Optional[str] = None) -> Config:
"""Recarga la configuración."""
global _config
_config = Config(config_path)
return _config