ForcePilot/backend/package/yuxi/channel/config/validation.py
Kris 4962d1f6f8 feat(channel/config): 新增完整的配置模块实现
新增了channel目录下的配置相关模块,包含默认配置、配置迁移、配置差异计算、文件加载器、重载计划、重载器以及配置校验功能,完善了配置管理体系
2026-05-21 10:24:03 +08:00

417 lines
15 KiB
Python

from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any
logger = logging.getLogger(__name__)
@dataclass
class ValidationIssue:
path: str
message: str
severity: str = "error"
allowed_values: list[str] | None = None
allowed_values_hidden_count: int | None = None
def to_dict(self) -> dict:
result = {"path": self.path, "message": self.message, "severity": self.severity}
if self.allowed_values is not None:
result["allowedValues"] = self.allowed_values
if self.allowed_values_hidden_count is not None:
result["allowedValuesHiddenCount"] = self.allowed_values_hidden_count
return result
@dataclass
class ValidationResult:
ok: bool = True
issues: list[ValidationIssue] = field(default_factory=list)
warnings: list[ValidationIssue] = field(default_factory=list)
legacy_issues: list[ValidationIssue] = field(default_factory=list)
def add_issue(
self,
path: str,
message: str,
severity: str = "error",
allowed_values: list[str] | None = None,
allowed_values_hidden_count: int | None = None,
) -> None:
issue = ValidationIssue(
path=path,
message=message,
severity=severity,
allowed_values=allowed_values,
allowed_values_hidden_count=allowed_values_hidden_count,
)
if severity == "warning":
self.warnings.append(issue)
elif severity == "legacy":
self.legacy_issues.append(issue)
else:
self.issues.append(issue)
self.ok = False
def merge(self, other: ValidationResult) -> None:
self.issues.extend(other.issues)
self.warnings.extend(other.warnings)
self.legacy_issues.extend(other.legacy_issues)
if not other.ok:
self.ok = False
def to_dict(self) -> dict:
return {
"ok": self.ok,
"issues": [i.to_dict() for i in self.issues],
"warnings": [w.to_dict() for w in self.warnings],
"legacy_issues": [li.to_dict() for li in self.legacy_issues],
}
# ── Schema Validation ────────────────────────────────────
def _collect_allowed_values_from_schema_node(node: dict) -> tuple[list[str], bool]:
if "const" in node:
return [str(node["const"])], False
if "enum" in node and isinstance(node["enum"], list):
return [str(v) for v in node["enum"]], False
node_type = node.get("type")
if node_type == "boolean" or (isinstance(node_type, list) and "boolean" in node_type):
return ["true", "false"], False
union_branches = node.get("anyOf") or node.get("oneOf")
if not isinstance(union_branches, list):
return [], False
collected: list[str] = []
for branch in union_branches:
if not isinstance(branch, dict):
return [], True
branch_values, incomplete = _collect_allowed_values_from_schema_node(branch)
if incomplete or not branch_values:
return [], True
collected.extend(branch_values)
return collected, False
def _lookup_schema_node(schema: dict, path_parts: list[str]) -> dict | None:
current: Any = schema
for part in path_parts:
if not isinstance(current, dict):
return None
properties = current.get("properties")
if isinstance(properties, dict):
current = properties.get(part)
elif "additionalProperties" in current:
current = current["additionalProperties"]
else:
return None
return current if isinstance(current, dict) else None
def _check_type(entry_value: Any, expected_type: str) -> bool:
if expected_type == "string":
return isinstance(entry_value, str)
if expected_type == "number":
return isinstance(entry_value, (int, float)) and not isinstance(entry_value, bool)
if expected_type == "integer":
return isinstance(entry_value, int) and not isinstance(entry_value, bool)
if expected_type == "boolean":
return isinstance(entry_value, bool)
if expected_type == "array":
return isinstance(entry_value, list)
if expected_type == "object":
return isinstance(entry_value, dict)
if expected_type == "null":
return entry_value is None
return True
def _matches_type(entry_value: Any, prop_type: str | list[str] | None) -> bool:
if prop_type is None:
return True
if isinstance(prop_type, list):
return any(_check_type(entry_value, t) for t in prop_type)
return _check_type(entry_value, prop_type)
def _validate_numeric_bounds(
entry_value: Any,
prop_schema: dict,
field_path: str,
key: str,
result: ValidationResult,
) -> None:
if not isinstance(entry_value, (int, float)) or isinstance(entry_value, bool):
return
minimum = prop_schema.get("minimum")
maximum = prop_schema.get("maximum")
exclusive_minimum = prop_schema.get("exclusiveMinimum")
exclusive_maximum = prop_schema.get("exclusiveMaximum")
if minimum is not None and entry_value < minimum:
result.add_issue(field_path, f"'{key}' must be >= {minimum}")
if maximum is not None and entry_value > maximum:
result.add_issue(field_path, f"'{key}' must be <= {maximum}")
if exclusive_minimum is not None and entry_value <= exclusive_minimum:
result.add_issue(field_path, f"'{key}' must be > {exclusive_minimum}")
if exclusive_maximum is not None and entry_value >= exclusive_maximum:
result.add_issue(field_path, f"'{key}' must be < {exclusive_maximum}")
def _validate_string_constraints(
entry_value: Any,
prop_schema: dict,
field_path: str,
key: str,
result: ValidationResult,
) -> None:
if not isinstance(entry_value, str):
return
min_length = prop_schema.get("minLength")
max_length = prop_schema.get("maxLength")
pattern = prop_schema.get("pattern")
format_ = prop_schema.get("format")
if min_length is not None and len(entry_value) < min_length:
result.add_issue(field_path, f"'{key}' must be at least {min_length} characters")
if max_length is not None and len(entry_value) > max_length:
result.add_issue(field_path, f"'{key}' must be at most {max_length} characters")
if pattern is not None:
import re
try:
if not re.search(pattern, entry_value):
result.add_issue(field_path, f"'{key}' does not match pattern '{pattern}'")
except re.error:
logger.warning("Invalid pattern in schema for '%s': %s", key, pattern)
if format_ == "email" and "@" not in entry_value:
result.add_issue(field_path, f"'{key}' must be a valid email address")
if format_ == "uri" and not (entry_value.startswith("http://") or entry_value.startswith("https://")):
result.add_issue(field_path, f"'{key}' must be a valid URI")
def _validate_array_constraints(
entry_value: Any,
prop_schema: dict,
field_path: str,
key: str,
result: ValidationResult,
) -> None:
if not isinstance(entry_value, list):
return
min_items = prop_schema.get("minItems")
max_items = prop_schema.get("maxItems")
items_schema = prop_schema.get("items")
if min_items is not None and len(entry_value) < min_items:
result.add_issue(field_path, f"'{key}' must have at least {min_items} items")
if max_items is not None and len(entry_value) > max_items:
result.add_issue(field_path, f"'{key}' must have at most {max_items} items")
if isinstance(items_schema, dict):
for i, item in enumerate(entry_value):
item_path = f"{field_path}[{i}]"
item_type = items_schema.get("type")
if not _matches_type(item, item_type):
type_label = item_type if isinstance(item_type, str) else " | ".join(item_type) if isinstance(item_type, list) else "any"
result.add_issue(item_path, f"item expects a {type_label} value")
if isinstance(item, dict) and isinstance(items_schema.get("properties"), dict):
_validate_entry_against_schema(item, items_schema, item_path, result)
def _validate_entry_against_schema(
entry: dict,
schema: dict,
prefix: str,
result: ValidationResult,
) -> None:
if schema.get("type") != "object":
return
properties = schema.get("properties")
required_fields: list[str] = schema.get("required", [])
if not isinstance(properties, dict):
return
seen_keys: set[str] = set()
for key, entry_value in entry.items():
if key in ("channel_type", "account_id"):
continue
seen_keys.add(key)
prop_schema = properties.get(key) if isinstance(properties, dict) else None
if prop_schema is None:
continue
field_path = f"{prefix}.{key}"
prop_type = prop_schema.get("type")
if not _matches_type(entry_value, prop_type):
help_text = prop_schema.get("description", "")
if isinstance(prop_type, list):
type_label = " | ".join(prop_type)
elif isinstance(prop_type, str):
type_label = prop_type
else:
type_label = "any"
msg = f"'{key}' expects a {type_label} value. {help_text}".strip()
result.add_issue(field_path, msg)
continue
if isinstance(entry_value, dict) and isinstance(prop_schema.get("properties"), dict):
_validate_entry_against_schema(entry_value, prop_schema, field_path, result)
if isinstance(entry_value, str) and not entry_value.strip() and key in required_fields:
help_text = prop_schema.get("description", "")
msg = f"'{key}' is required but empty. {help_text}".strip()
result.add_issue(field_path, msg)
continue
if isinstance(entry_value, str) and entry_value.strip():
allowed, incomplete = _collect_allowed_values_from_schema_node(prop_schema)
if not incomplete and allowed and entry_value not in allowed:
result.add_issue(
field_path,
f"'{key}' has invalid value '{entry_value}'",
allowed_values=allowed,
)
_validate_numeric_bounds(entry_value, prop_schema, field_path, key, result)
_validate_string_constraints(entry_value, prop_schema, field_path, key, result)
_validate_array_constraints(entry_value, prop_schema, field_path, key, result)
for req_key in required_fields:
if req_key not in seen_keys:
prop_schema = properties.get(req_key, {})
help_text = prop_schema.get("description", "")
msg = f"missing required field '{req_key}'. {help_text}".strip()
result.add_issue(f"{prefix}.{req_key}", msg)
# ── Plugin Validation ────────────────────────────────────
def validate_channel_plugin(plugin: Any, result: ValidationResult) -> None:
from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol
if not isinstance(plugin, ConfigProtocol):
return
plugin_id = getattr(plugin, "id", None)
if not plugin_id or not isinstance(plugin_id, str) or not plugin_id.strip():
result.add_issue("plugin", "channel plugin missing id")
return
prefix = f"plugin.{plugin_id}"
if not callable(getattr(plugin, "is_configured", None)):
result.add_issue(prefix, "missing is_configured method")
if not callable(getattr(plugin, "list_account_ids", None)):
result.add_issue(prefix, "missing list_account_ids method")
if not callable(getattr(plugin, "resolve_account", None)):
result.add_issue(prefix, "missing resolve_account method")
if isinstance(plugin, ConfigSchemaProtocol):
try:
schema = plugin.config_schema()
if not isinstance(schema, dict) or not schema:
result.add_issue(prefix, "config_schema() returned empty or invalid schema", "warning")
except Exception as e:
result.add_issue(prefix, f"config_schema() raised: {e}", "warning")
# ── Main Validation ──────────────────────────────────────
async def validate_all_channel_configs(
plugins: list,
config_entries: list[dict],
*,
validate_schema: bool = True,
validate_plugins: bool = False,
) -> ValidationResult:
result = ValidationResult()
from yuxi.channel.protocols import ConfigProtocol, ConfigSchemaProtocol
from yuxi.channel.secrets.models import is_secret_ref
configured_plugins = [p for p in plugins if isinstance(p, ConfigProtocol)]
if not configured_plugins:
result.add_issue("global", "No ConfigProtocol plugins registered")
return result
if validate_plugins:
for plugin in configured_plugins:
validate_channel_plugin(plugin, result)
plugin_ids = {p.id for p in configured_plugins}
for entry in config_entries:
channel_type = entry.get("channel_type", "unknown")
account_id = entry.get("account_id", "unknown")
prefix = f"channels.{channel_type}.{account_id}"
if channel_type not in plugin_ids:
result.add_issue(prefix, f"No plugin registered for channel_type '{channel_type}'")
continue
plugin = next((p for p in configured_plugins if p.id == channel_type), None)
if plugin is None:
result.add_issue(prefix, f"Plugin '{channel_type}' disappeared during validation")
continue
try:
configured = plugin.is_configured(entry)
except Exception as e:
result.add_issue(prefix, f"is_configured check failed: {e}")
continue
if not configured:
reason = ""
if callable(getattr(plugin, "unconfigured_reason", None)):
try:
reason = plugin.unconfigured_reason(entry, {}) or ""
except Exception:
pass
msg = "Account not fully configured"
if reason:
msg = f"{msg}: {reason}"
result.add_issue(prefix, msg, "warning")
continue
if validate_schema and isinstance(plugin, ConfigSchemaProtocol):
try:
schema = plugin.config_schema()
if isinstance(schema, dict) and schema:
_validate_entry_against_schema(entry, schema, prefix, result)
except Exception as e:
logger.warning("Schema validation skipped for %s: %s", channel_type, e)
has_secret_refs = any(
is_secret_ref(v) for v in entry.values() if isinstance(v, dict)
)
if has_secret_refs:
try:
from yuxi.channel.secrets import SecretResolver
resolver = SecretResolver()
resolved = await resolver.resolve(entry)
for issue in resolved.issues:
result.add_issue(f"{prefix}.secrets", str(issue))
for warning in resolved.warnings:
result.add_issue(f"{prefix}.secrets", str(warning), "warning")
except Exception as e:
result.add_issue(f"{prefix}.secrets", f"Secret resolution failed: {e}")
return result