Files
tracer-cloud--opensre/tests/synthetic/rds_postgres/scenario_loader.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

483 lines
18 KiB
Python

from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal, cast
import yaml
from tests.synthetic.schemas import (
GoldenTrajectorySchema,
ScenarioEvidence,
ScenarioMetadataSchema,
validate_alert,
validate_answer_key,
validate_cloudwatch_metrics,
validate_ec2_instances_by_tag,
validate_elb_target_health,
validate_generic_evidence,
validate_performance_insights,
validate_rds_events,
validate_scenario_metadata,
)
SUITE_DIR = Path(__file__).resolve().parent
@dataclass(frozen=True)
class ScenarioMetadata:
schema_version: str
scenario_id: str
engine: str
engine_version: str
instance_class: str
region: str
db_instance_identifier: str
db_cluster: str
failure_mode: str
severity: str
available_evidence: list[str]
scenario_difficulty: int = 1
adversarial_signals: list[str] = () # type: ignore[assignment]
depends_on: str = ""
TrajectoryMatching = Literal["strict", "lcs", "set"]
@dataclass(frozen=True)
class GoldenTrajectoryConfig:
ordered_actions: list[str]
matching: TrajectoryMatching = "lcs"
max_edit_distance: int | None = None
max_extra_actions: int | None = None
max_redundancy: int | None = None
max_loops: int | None = None
@dataclass(frozen=True)
class ScenarioAnswerKey:
root_cause_category: str
required_keywords: list[str]
model_response: str
equivalent_root_cause_categories: tuple[str, ...] = ()
forbidden_categories: list[str] = () # type: ignore[assignment]
forbidden_keywords: list[str] = () # type: ignore[assignment]
required_evidence_sources: list[str] = () # type: ignore[assignment]
optimal_trajectory: list[str] = () # type: ignore[assignment]
max_investigation_loops: int = 1
ruling_out_keywords: list[str] = () # type: ignore[assignment]
required_queries: list[str] = () # type: ignore[assignment]
golden_trajectory: GoldenTrajectoryConfig | None = None
@dataclass(frozen=True)
class ScenarioFixture:
scenario_id: str
scenario_dir: Path
alert: dict[str, Any]
evidence: ScenarioEvidence
metadata: ScenarioMetadata
answer_key: ScenarioAnswerKey
problem_md: str
def _parse_trajectory_matching(value: Any) -> TrajectoryMatching:
if value in {"strict", "lcs", "set"}:
return cast(TrajectoryMatching, value)
raise ValueError(
"answer.yml: 'golden_trajectory.matching' must be one of "
"'strict', 'lcs', or 'set' when present"
)
def _parse_non_negative_int(
golden_trajectory: GoldenTrajectorySchema | dict[str, Any], field: str
) -> int | None:
value = golden_trajectory.get(field)
if value is None:
return None
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
raise ValueError(
f"answer.yml: 'golden_trajectory.{field}' must be a non-negative integer when present"
)
return cast(int, value)
def _read_json(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Expected JSON object in {path}")
return payload
def _read_yaml(path: Path) -> dict[str, Any]:
payload = yaml.safe_load(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"Expected YAML object in {path}")
return payload
# ---------------------------------------------------------------------------
# Base-inheritance helpers
# ---------------------------------------------------------------------------
def _resolve_base_dir(suite_dir: Path, base_id: str) -> Path:
"""Find the base scenario directory by its directory name (e.g. '000-healthy')."""
base_dir = suite_dir / base_id
if not base_dir.is_dir():
raise ValueError(f"Base scenario '{base_id}' not found at {base_dir}")
base_raw = _read_yaml(base_dir / "scenario.yml")
if "base" in base_raw:
raise ValueError(
f"Chained inheritance is not supported: base scenario '{base_id}' "
f"itself declares base '{base_raw['base']}'"
)
return base_dir
def _merge_scenario_yaml(
base_raw: dict[str, Any],
scenario_raw: dict[str, Any],
) -> dict[str, Any]:
"""Shallow-merge scenario overrides on top of base metadata.
scenario_raw values win. The ``base`` directive is consumed and removed.
"""
merged = {**base_raw, **{k: v for k, v in scenario_raw.items() if k != "base"}}
merged.pop("base", None)
return merged
def _resolve_evidence_path(
scenario_dir: Path,
base_dir: Path | None,
filename: str,
) -> Path:
"""Return the scenario's own evidence file if it exists, otherwise the base's."""
for search_dir in (scenario_dir, base_dir):
if search_dir is None:
continue
candidate = search_dir / filename
if candidate.exists():
return candidate
raise FileNotFoundError(
f"Evidence '{filename}' not found in {scenario_dir}"
+ (f" or base {base_dir}" if base_dir else "")
)
def _has_split_cloudwatch_metrics(scenario_dir: Path) -> bool:
"""Check whether a directory uses per-metric prefixed files."""
return (scenario_dir / "aws_cloudwatch_metrics_envelope.json").exists()
def _load_cloudwatch_metrics_split(scenario_dir: Path) -> dict[str, Any]:
"""Assemble CloudWatch metrics from prefixed per-metric files.
Expects ``aws_cloudwatch_metrics_envelope.json`` (shared metadata) and
``aws_cloudwatch_metrics_<MetricName>.json`` files in *scenario_dir*.
"""
envelope = _read_json(scenario_dir / "aws_cloudwatch_metrics_envelope.json")
prefix = "aws_cloudwatch_metrics_"
metrics = []
for f in sorted(scenario_dir.glob(f"{prefix}*.json")):
if f.name == f"{prefix}envelope.json":
continue
metrics.append(_read_json(f))
envelope["metric_data_results"] = metrics
return envelope
def _load_cloudwatch_metrics(
scenario_dir: Path,
base_dir: Path | None,
) -> dict[str, Any]:
"""Load CloudWatch metrics — consolidated file or per-metric split."""
# 1. Scenario has a consolidated file
single = scenario_dir / "aws_cloudwatch_metrics.json"
if single.is_file():
return _read_json(single)
# 2. Scenario has per-metric split files
if _has_split_cloudwatch_metrics(scenario_dir):
return _load_cloudwatch_metrics_split(scenario_dir)
# 3. Fall back to base
if base_dir is not None:
base_single = base_dir / "aws_cloudwatch_metrics.json"
if base_single.is_file():
return _read_json(base_single)
if _has_split_cloudwatch_metrics(base_dir):
return _load_cloudwatch_metrics_split(base_dir)
raise FileNotFoundError(
f"CloudWatch metrics not found in {scenario_dir}"
+ (f" or base {base_dir}" if base_dir else "")
)
# ---------------------------------------------------------------------------
# Parsing helpers
# ---------------------------------------------------------------------------
def _validated_metadata(raw: dict[str, Any]) -> ScenarioMetadata:
"""Validate a (possibly merged) raw dict and return a ScenarioMetadata."""
validated: ScenarioMetadataSchema = validate_scenario_metadata(raw)
return ScenarioMetadata(
schema_version=validated["schema_version"],
scenario_id=validated["scenario_id"],
engine=validated["engine"],
engine_version=validated["engine_version"],
instance_class=validated["instance_class"],
region=validated["region"],
db_instance_identifier=validated["db_instance_identifier"],
db_cluster=validated.get("db_cluster", ""),
failure_mode=validated["failure_mode"],
severity=validated["severity"],
available_evidence=list(validated["available_evidence"]),
scenario_difficulty=validated.get("scenario_difficulty", 1), # type: ignore[arg-type]
adversarial_signals=list(validated.get("adversarial_signals") or []),
depends_on=validated.get("depends_on", ""), # type: ignore[arg-type]
)
def _parse_scenario_yaml(path: Path) -> tuple[ScenarioMetadata, Path | None]:
"""Parse scenario.yml, resolving base inheritance if declared.
Returns (metadata, base_dir) where base_dir is the resolved base scenario
directory, or None if no ``base`` field was declared.
"""
raw = _read_yaml(path)
base_id = raw.get("base")
base_dir: Path | None = None
if base_id:
suite_dir = path.parent.parent
base_dir = _resolve_base_dir(suite_dir, base_id)
base_raw = _read_yaml(base_dir / "scenario.yml")
raw = _merge_scenario_yaml(base_raw, raw)
return _validated_metadata(raw), base_dir
def _parse_answer_yaml(path: Path) -> ScenarioAnswerKey:
payload = _read_yaml(path)
validated = validate_answer_key(payload)
golden_trajectory_raw = validated.get("golden_trajectory")
golden_trajectory: GoldenTrajectoryConfig | None = None
if isinstance(golden_trajectory_raw, dict):
ordered_actions_raw = golden_trajectory_raw.get("ordered_actions")
if not isinstance(ordered_actions_raw, list) or not ordered_actions_raw:
raise ValueError(
"answer.yml: 'golden_trajectory.ordered_actions' must be a non-empty list "
"of strings when present"
)
ordered_actions = [action.strip() for action in ordered_actions_raw]
matching = _parse_trajectory_matching(golden_trajectory_raw.get("matching", "lcs"))
golden_trajectory = GoldenTrajectoryConfig(
ordered_actions=ordered_actions,
matching=matching,
max_edit_distance=_parse_non_negative_int(golden_trajectory_raw, "max_edit_distance"),
max_extra_actions=_parse_non_negative_int(golden_trajectory_raw, "max_extra_actions"),
max_redundancy=_parse_non_negative_int(golden_trajectory_raw, "max_redundancy"),
max_loops=_parse_non_negative_int(golden_trajectory_raw, "max_loops"),
)
equivalent_raw = validated.get("equivalent_root_cause_categories") or []
equivalent_root_cause_categories = tuple(
str(item).strip() for item in equivalent_raw if str(item).strip()
)
return ScenarioAnswerKey(
root_cause_category=validated["root_cause_category"].strip(),
required_keywords=[k.strip() for k in validated["required_keywords"]],
model_response=validated["model_response"].strip(),
equivalent_root_cause_categories=equivalent_root_cause_categories,
forbidden_categories=list(validated.get("forbidden_categories") or []),
forbidden_keywords=list(validated.get("forbidden_keywords") or []),
required_evidence_sources=list(validated.get("required_evidence_sources") or []),
optimal_trajectory=list(validated.get("optimal_trajectory") or []),
max_investigation_loops=int(validated.get("max_investigation_loops") or 1),
ruling_out_keywords=list(validated.get("ruling_out_keywords") or []),
required_queries=list(validated.get("required_queries") or []),
golden_trajectory=golden_trajectory,
)
def _build_problem_md(alert: dict[str, Any], metadata: ScenarioMetadata) -> str:
title = str(alert.get("title") or metadata.scenario_id)
annotations = alert.get("commonAnnotations", {}) or {}
parts = [
f"# {title}",
(
f"Service: RDS {metadata.engine.upper()}"
f" | Severity: {metadata.severity}"
f" | Scenario: {metadata.failure_mode}"
),
f"Scenario ID: {metadata.scenario_id}",
f"DB instance: {metadata.db_instance_identifier}",
]
if metadata.db_cluster:
parts.append(f"DB cluster: {metadata.db_cluster}")
summary = annotations.get("summary")
if summary:
parts.append(f"\nSummary: {summary}")
error = annotations.get("error")
if error and error != summary:
parts.append(f"\nError: {error}")
suspected = annotations.get("suspected_symptom")
if suspected:
parts.append(f"\nObserved symptom: {suspected}")
return "\n".join(parts)
def _build_evidence(
scenario_dir: Path,
available_evidence: list[str],
base_dir: Path | None = None,
) -> ScenarioEvidence:
"""Load only the evidence sources declared in scenario.yml:available_evidence.
When *base_dir* is set, evidence files missing from *scenario_dir* are
resolved from the base scenario directory (file-level fallback).
"""
aws_cloudwatch_metrics = None
aws_rds_events = None
aws_performance_insights = None
ec2_instances_by_tag = None
elb_target_health = None
k8s_events = None
k8s_pod_metrics = None
k8s_node_metrics = None
k8s_dns_metrics = None
k8s_mesh_metrics = None
k8s_rollout = None
if "aws_cloudwatch_metrics" in available_evidence:
raw = _load_cloudwatch_metrics(scenario_dir, base_dir)
aws_cloudwatch_metrics = validate_cloudwatch_metrics(raw)
if "aws_rds_events" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "aws_rds_events.json")
raw_events = validate_rds_events(_read_json(path))
aws_rds_events = raw_events.get("events", [])
if "aws_performance_insights" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "aws_performance_insights.json")
aws_performance_insights = validate_performance_insights(_read_json(path))
if "ec2_instances_by_tag" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "ec2_instances_by_tag.json")
ec2_instances_by_tag = validate_ec2_instances_by_tag(_read_json(path))
if "elb_target_health" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "elb_target_health.json")
elb_target_health = validate_elb_target_health(_read_json(path))
if "k8s_events" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_events.json")
k8s_events = validate_generic_evidence(_read_json(path), filename="k8s_events.json")
if "k8s_pod_metrics" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_pod_metrics.json")
k8s_pod_metrics = validate_generic_evidence(
_read_json(path), filename="k8s_pod_metrics.json"
)
if "k8s_node_metrics" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_node_metrics.json")
k8s_node_metrics = validate_generic_evidence(
_read_json(path), filename="k8s_node_metrics.json"
)
if "k8s_dns_metrics" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_dns_metrics.json")
k8s_dns_metrics = validate_generic_evidence(
_read_json(path), filename="k8s_dns_metrics.json"
)
if "k8s_mesh_metrics" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_mesh_metrics.json")
k8s_mesh_metrics = validate_generic_evidence(
_read_json(path), filename="k8s_mesh_metrics.json"
)
if "k8s_rollout" in available_evidence:
path = _resolve_evidence_path(scenario_dir, base_dir, "k8s_rollout.json")
k8s_rollout = validate_generic_evidence(_read_json(path), filename="k8s_rollout.json")
return ScenarioEvidence(
aws_cloudwatch_metrics=aws_cloudwatch_metrics,
aws_rds_events=aws_rds_events,
aws_performance_insights=aws_performance_insights,
ec2_instances_by_tag=ec2_instances_by_tag,
elb_target_health=elb_target_health,
k8s_events=k8s_events,
k8s_pod_metrics=k8s_pod_metrics,
k8s_node_metrics=k8s_node_metrics,
k8s_dns_metrics=k8s_dns_metrics,
k8s_mesh_metrics=k8s_mesh_metrics,
k8s_rollout=k8s_rollout,
)
def _is_schema_v3(schema_version: str) -> bool:
normalized = schema_version.strip().lower().replace("-", "_")
return normalized in {"schema_v3", "v3", "3", "3.0"}
def _is_complex_scenario(metadata: ScenarioMetadata) -> bool:
return metadata.scenario_difficulty >= 3
def _validate_schema_specific_answer_requirements(
metadata: ScenarioMetadata,
answer_key: ScenarioAnswerKey,
) -> None:
if (
_is_schema_v3(metadata.schema_version)
and _is_complex_scenario(metadata)
and not answer_key.required_evidence_sources
):
raise ValueError(
"answer.yml: 'required_evidence_sources' must be a non-empty list "
"for schema_v3 complex scenarios (scenario_difficulty >= 3)"
)
def load_scenario(scenario_dir: Path) -> ScenarioFixture:
metadata, base_dir = _parse_scenario_yaml(scenario_dir / "scenario.yml")
alert_path = _resolve_evidence_path(scenario_dir, base_dir, "alert.json")
alert = cast(dict[str, Any], validate_alert(_read_json(alert_path)))
evidence = _build_evidence(scenario_dir, metadata.available_evidence, base_dir)
answer_key = _parse_answer_yaml(scenario_dir / "answer.yml")
_validate_schema_specific_answer_requirements(metadata, answer_key)
problem_md = _build_problem_md(alert, metadata)
return ScenarioFixture(
scenario_id=scenario_dir.name,
scenario_dir=scenario_dir,
alert=alert,
evidence=evidence,
metadata=metadata,
answer_key=answer_key,
problem_md=problem_md,
)
def load_all_scenarios(root_dir: Path | None = None) -> list[ScenarioFixture]:
base_dir = root_dir or SUITE_DIR
scenario_dirs = sorted(
path for path in base_dir.iterdir() if path.is_dir() and path.name[:3].isdigit()
)
return [load_scenario(path) for path in scenario_dirs]