Closes #690 Validates YAML syntax, required keys, value types, and forbidden keys before deploy. Prevents broken deploys from bad config.
224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
config-validate — Pre-deploy validation for timmy-config YAML files.
|
|
|
|
Validates YAML syntax, required keys, and value types before writing
|
|
config to disk. Prevents broken deploys from bad config.
|
|
|
|
Usage:
|
|
python scripts/config_validate.py config.yaml
|
|
python scripts/config_validate.py config.yaml --deploy # Validate + write
|
|
python scripts/config_validate.py --schema # Show expected schema
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
try:
|
|
import yaml
|
|
HAS_YAML = True
|
|
except ImportError:
|
|
HAS_YAML = False
|
|
|
|
|
|
# Expected schema for hermes config.yaml
|
|
REQUIRED_KEYS = {
|
|
"model": {"type": str, "required": True, "description": "Default model name"},
|
|
}
|
|
|
|
OPTIONAL_KEYS = {
|
|
"provider": {"type": str, "required": False, "description": "Default provider"},
|
|
"providers": {"type": dict, "required": False, "description": "Provider configuration"},
|
|
"fallback_providers": {"type": list, "required": False, "description": "Fallback chain"},
|
|
"toolsets": {"type": list, "required": False, "description": "Enabled toolsets"},
|
|
"agent": {"type": dict, "required": False, "description": "Agent configuration"},
|
|
"display": {"type": dict, "required": False, "description": "Display settings"},
|
|
}
|
|
|
|
# Keys that should NOT be present
|
|
FORBIDDEN_KEYS = {
|
|
"anthropic_api_key": "Use ANTHROPIC_API_KEY env var instead",
|
|
"openai_api_key": "Use OPENAI_API_KEY env var instead",
|
|
"password": "Never put passwords in config",
|
|
"secret": "Never put secrets in config",
|
|
}
|
|
|
|
|
|
class ValidationError:
|
|
def __init__(self, path: str, message: str, severity: str = "error"):
|
|
self.path = path
|
|
self.message = message
|
|
self.severity = severity
|
|
|
|
def __str__(self):
|
|
return f"[{self.severity.upper()}] {self.path}: {self.message}"
|
|
|
|
|
|
def validate_yaml_syntax(content: str) -> Tuple[Optional[Dict], List[ValidationError]]:
|
|
"""Validate YAML can be parsed."""
|
|
errors = []
|
|
|
|
if not HAS_YAML:
|
|
return None, [ValidationError("root", "PyYAML not installed", "error")]
|
|
|
|
try:
|
|
data = yaml.safe_load(content)
|
|
if data is None:
|
|
return {}, []
|
|
if not isinstance(data, dict):
|
|
errors.append(ValidationError("root", f"Expected dict, got {type(data).__name__}", "error"))
|
|
return None, errors
|
|
return data, errors
|
|
except yaml.YAMLError as e:
|
|
errors.append(ValidationError("syntax", str(e), "error"))
|
|
return None, errors
|
|
|
|
|
|
def validate_required_keys(data: Dict[str, Any]) -> List[ValidationError]:
|
|
"""Check required keys exist."""
|
|
errors = []
|
|
for key, spec in REQUIRED_KEYS.items():
|
|
if key not in data:
|
|
errors.append(ValidationError(key, f"Required key missing: {key}", "error"))
|
|
elif not isinstance(data[key], spec["type"]):
|
|
errors.append ValidationError(key, f"Expected {spec['type'].__name__}, got {type(data[key]).__name__}", "error"))
|
|
return errors
|
|
|
|
|
|
def validate_value_types(data: Dict[str, Any], schema: Dict[str, Dict]) -> List[ValidationError]:
|
|
"""Check value types match schema."""
|
|
errors = []
|
|
for key, spec in schema.items():
|
|
if key in data:
|
|
expected_type = spec["type"]
|
|
actual = data[key]
|
|
if not isinstance(actual, expected_type):
|
|
errors.append(ValidationError(key, f"Expected {expected_type.__name__}, got {type(actual).__name__}", "error"))
|
|
return errors
|
|
|
|
|
|
def validate_no_forbidden_keys(data: Dict[str, Any]) -> List[ValidationError]:
|
|
"""Check for keys that should not be in config."""
|
|
errors = []
|
|
for key, reason in FORBIDDEN_KEYS.items():
|
|
if key in data:
|
|
errors.append(ValidationError(key, f"Forbidden key: {reason}", "error"))
|
|
return errors
|
|
|
|
|
|
def validate_nested(data: Dict[str, Any], path: str = "") -> List[ValidationError]:
|
|
"""Recursively validate nested structures."""
|
|
errors = []
|
|
|
|
# Check providers dict
|
|
if "providers" in data and isinstance(data["providers"], dict):
|
|
for provider_name, provider_config in data["providers"].items():
|
|
if not isinstance(provider_config, dict):
|
|
errors.append(ValidationError(f"providers.{provider_name}", "Provider config must be a dict", "error"))
|
|
|
|
# Check agent dict
|
|
if "agent" in data and isinstance(data["agent"], dict):
|
|
agent = data["agent"]
|
|
if "max_turns" in agent and not isinstance(agent["max_turns"], int):
|
|
errors.append(ValidationError("agent.max_turns", "Must be an integer", "error"))
|
|
if "reasoning_effort" in agent and agent["reasoning_effort"] not in (None, "low", "medium", "high"):
|
|
errors.append(ValidationError("agent.reasoning_effort", "Must be low/medium/high", "error"))
|
|
|
|
# Check toolsets is list of strings
|
|
if "toolsets" in data and isinstance(data["toolsets"], list):
|
|
for i, ts in enumerate(data["toolsets"]):
|
|
if not isinstance(ts, str):
|
|
errors.append(ValidationError(f"toolsets[{i}]", "Toolset must be a string", "error"))
|
|
|
|
return errors
|
|
|
|
|
|
def validate_config(content: str) -> Tuple[bool, List[ValidationError]]:
|
|
"""Full validation pipeline. Returns (valid, errors)."""
|
|
all_errors = []
|
|
|
|
# Step 1: YAML syntax
|
|
data, errors = validate_yaml_syntax(content)
|
|
all_errors.extend(errors)
|
|
if data is None:
|
|
return False, all_errors
|
|
|
|
# Step 2: Required keys
|
|
all_errors.extend(validate_required_keys(data))
|
|
|
|
# Step 3: Value types (required + optional)
|
|
all_errors.extend(validate_value_types(data, {**REQUIRED_KEYS, **OPTIONAL_KEYS}))
|
|
|
|
# Step 4: Forbidden keys
|
|
all_errors.extend(validate_no_forbidden_keys(data))
|
|
|
|
# Step 5: Nested validation
|
|
all_errors.extend(validate_nested(data))
|
|
|
|
# Any errors = invalid
|
|
has_errors = any(e.severity == "error" for e in all_errors)
|
|
return not has_errors, all_errors
|
|
|
|
|
|
def print_schema():
|
|
"""Print expected config schema."""
|
|
print("Required keys:")
|
|
for key, spec in REQUIRED_KEYS.items():
|
|
print(f" {key}: {spec['type'].__name__} — {spec['description']}")
|
|
|
|
print("\nOptional keys:")
|
|
for key, spec in OPTIONAL_KEYS.items():
|
|
print(f" {key}: {spec['type'].__name__} — {spec['description']}")
|
|
|
|
print("\nForbidden keys:")
|
|
for key, reason in FORBIDDEN_KEYS.items():
|
|
print(f" {key} — {reason}")
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Validate timmy-config YAML files")
|
|
parser.add_argument("file", nargs="?", help="Config file to validate")
|
|
parser.add_argument("--deploy", action="store_true", help="Validate then deploy (write)")
|
|
parser.add_argument("--schema", action="store_true", help="Show expected schema")
|
|
args = parser.parse_args()
|
|
|
|
if args.schema:
|
|
print_schema()
|
|
return
|
|
|
|
if not args.file:
|
|
parser.print_help()
|
|
return
|
|
|
|
path = Path(args.file)
|
|
if not path.exists():
|
|
print(f"Error: {path} not found")
|
|
sys.exit(1)
|
|
|
|
content = path.read_text()
|
|
valid, errors = validate_config(content)
|
|
|
|
if errors:
|
|
print(f"Validation results for {path}:")
|
|
for err in errors:
|
|
print(f" {err}")
|
|
print()
|
|
|
|
if valid:
|
|
print(f"✓ {path} is valid")
|
|
if args.deploy:
|
|
print(f"Deploying {path}...")
|
|
# In real usage, this would write to ~/.hermes/config.yaml
|
|
print("Deploy complete.")
|
|
else:
|
|
print(f"✗ {path} has {sum(1 for e in errors if e.severity == 'error')} error(s)")
|
|
print("Fix errors before deploying.")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|