- 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>
287 lines
8.7 KiB
Python
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
|