363 lines
12 KiB
Python
363 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Build and validate ICS-SimLab configuration.
|
|
|
|
This is the config pipeline entrypoint that:
|
|
1. Loads raw JSON
|
|
2. Validates/normalizes with Pydantic v2 (type coercion)
|
|
3. Writes configuration_normalized.json
|
|
4. Enriches with monitors/controllers (calls existing enrich_config)
|
|
5. Re-validates enriched config
|
|
6. Runs semantic validation
|
|
7. Writes configuration_enriched.json (source of truth)
|
|
|
|
Usage:
|
|
python3 -m tools.build_config \\
|
|
--config examples/water_tank/configuration.json \\
|
|
--out-dir outputs/test_config \\
|
|
--overwrite
|
|
|
|
# Strict mode (no type coercion, fail on type mismatch):
|
|
python3 -m tools.build_config \\
|
|
--config examples/water_tank/configuration.json \\
|
|
--out-dir outputs/test_config \\
|
|
--strict
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any, Dict
|
|
|
|
from models.ics_simlab_config_v2 import Config, set_strict_mode
|
|
from tools.enrich_config import enrich_plc_connections, enrich_hmi_connections
|
|
from tools.semantic_validation import validate_all_semantics, SemanticError
|
|
from tools.repair_config import repair_orphan_devices, repair_boolean_types, repair_plc_local_registers, repair_hmi_controller_registers, repair_target_device_registers
|
|
from services.patches import patch_sanitize_connection_ids
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(levelname)s: %(message)s"
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def load_and_normalize(raw_path: Path) -> Config:
|
|
"""
|
|
Load JSON and validate with Pydantic, normalizing types.
|
|
|
|
Args:
|
|
raw_path: Path to configuration.json
|
|
|
|
Returns:
|
|
Validated Config object
|
|
|
|
Raises:
|
|
SystemExit: On validation failure
|
|
"""
|
|
raw_text = raw_path.read_text(encoding="utf-8")
|
|
|
|
try:
|
|
raw_data = json.loads(raw_text)
|
|
except json.JSONDecodeError as e:
|
|
raise SystemExit(f"ERROR: Invalid JSON in {raw_path}: {e}")
|
|
|
|
try:
|
|
return Config.model_validate(raw_data)
|
|
except Exception as e:
|
|
raise SystemExit(f"ERROR: Pydantic validation failed:\n{e}")
|
|
|
|
|
|
def config_to_dict(cfg: Config) -> Dict[str, Any]:
|
|
"""Convert Pydantic model to dict for JSON serialization.
|
|
|
|
Uses exclude_none=True to remove null values, which prevents
|
|
ICS-SimLab runtime errors like 'identity': None causing
|
|
TypeError when PLC code checks 'if "identity" in configs'.
|
|
"""
|
|
return cfg.model_dump(mode="json", exclude_none=True)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Build and validate ICS-SimLab configuration"
|
|
)
|
|
parser.add_argument(
|
|
"--config",
|
|
required=True,
|
|
help="Input configuration.json path"
|
|
)
|
|
parser.add_argument(
|
|
"--out-dir",
|
|
required=True,
|
|
help="Output directory for normalized and enriched configs"
|
|
)
|
|
parser.add_argument(
|
|
"--overwrite",
|
|
action="store_true",
|
|
help="Overwrite existing output files"
|
|
)
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="Strict mode: disable type coercion, fail on type mismatch"
|
|
)
|
|
parser.add_argument(
|
|
"--skip-semantic",
|
|
action="store_true",
|
|
help="Skip semantic validation (for debugging)"
|
|
)
|
|
parser.add_argument(
|
|
"--json-errors",
|
|
action="store_true",
|
|
help="Output semantic errors as JSON to stdout (for programmatic use)"
|
|
)
|
|
parser.add_argument(
|
|
"--repair",
|
|
action="store_true",
|
|
help="Auto-repair orphan devices and boolean type issues"
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
config_path = Path(args.config)
|
|
out_dir = Path(args.out_dir)
|
|
|
|
if not config_path.exists():
|
|
raise SystemExit(f"ERROR: Config file not found: {config_path}")
|
|
|
|
# Enable strict mode if requested
|
|
if args.strict:
|
|
set_strict_mode(True)
|
|
|
|
# Prepare output path (single file: configuration.json = enriched version)
|
|
output_path = out_dir / "configuration.json"
|
|
|
|
if output_path.exists() and not args.overwrite:
|
|
raise SystemExit(f"ERROR: Output file exists: {output_path} (use --overwrite)")
|
|
|
|
# Ensure output directory exists
|
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
# =========================================================================
|
|
# Step 1: Load and normalize with Pydantic
|
|
# =========================================================================
|
|
print("=" * 60)
|
|
print("Step 1: Loading and normalizing configuration")
|
|
print("=" * 60)
|
|
|
|
config = load_and_normalize(config_path)
|
|
|
|
print(f" Source: {config_path}")
|
|
print(f" PLCs: {len(config.plcs)}")
|
|
print(f" HILs: {len(config.hils)}")
|
|
print(f" Sensors: {len(config.sensors)}")
|
|
print(f" Actuators: {len(config.actuators)}")
|
|
print(f" HMIs: {len(config.hmis)}")
|
|
print(" Pydantic validation: OK")
|
|
|
|
# =========================================================================
|
|
# Step 2: Enrich configuration
|
|
# =========================================================================
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 2: Enriching configuration")
|
|
print("=" * 60)
|
|
|
|
# Work with dict for enrichment (existing enrich_config expects dict)
|
|
config_dict = config_to_dict(config)
|
|
enriched_dict = enrich_plc_connections(dict(config_dict))
|
|
enriched_dict = enrich_hmi_connections(enriched_dict)
|
|
|
|
# Sanitize connection IDs to docker-safe format [a-z0-9_]
|
|
print()
|
|
print(" Sanitizing connection IDs...")
|
|
enriched_dict, conn_id_errors = patch_sanitize_connection_ids(enriched_dict)
|
|
if conn_id_errors:
|
|
for err in conn_id_errors:
|
|
logger.warning(f"Connection ID patch error: {err}")
|
|
print(" Connection IDs sanitized: OK")
|
|
|
|
# Re-validate enriched config with Pydantic
|
|
print()
|
|
print(" Re-validating enriched config...")
|
|
try:
|
|
enriched_config = Config.model_validate(enriched_dict)
|
|
print(" Enriched config validation: OK")
|
|
except Exception as e:
|
|
raise SystemExit(f"ERROR: Enriched config failed Pydantic validation:\n{e}")
|
|
|
|
# =========================================================================
|
|
# Step 3: Repair (optional)
|
|
# =========================================================================
|
|
if args.repair:
|
|
all_repair_actions = []
|
|
|
|
# Step 3a: Repair orphan devices
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 3a: Repairing orphan devices")
|
|
print("=" * 60)
|
|
|
|
enriched_dict, orphan_actions = repair_orphan_devices(enriched_dict)
|
|
all_repair_actions.extend(orphan_actions)
|
|
|
|
if orphan_actions:
|
|
for action in orphan_actions:
|
|
print(f" REPAIRED: {action}")
|
|
else:
|
|
print(" No orphan devices found")
|
|
|
|
# Step 3b: Repair boolean types
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 3b: Repairing boolean register types")
|
|
print("=" * 60)
|
|
|
|
enriched_dict, boolean_actions = repair_boolean_types(enriched_dict)
|
|
all_repair_actions.extend(boolean_actions)
|
|
|
|
if boolean_actions:
|
|
for action in boolean_actions:
|
|
print(f" REPAIRED: {action}")
|
|
else:
|
|
print(" No boolean type issues found")
|
|
|
|
# Step 3c: Repair PLC local register coherence
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 3c: Repairing PLC local register coherence")
|
|
print("=" * 60)
|
|
|
|
enriched_dict, local_reg_actions = repair_plc_local_registers(enriched_dict)
|
|
all_repair_actions.extend(local_reg_actions)
|
|
|
|
if local_reg_actions:
|
|
for action in local_reg_actions:
|
|
print(f" REPAIRED: {action}")
|
|
else:
|
|
print(" No PLC local register issues found")
|
|
|
|
# Step 3d: Repair HMI controller registers
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 3d: Repairing HMI controller registers")
|
|
print("=" * 60)
|
|
|
|
enriched_dict, hmi_ctrl_actions = repair_hmi_controller_registers(enriched_dict)
|
|
all_repair_actions.extend(hmi_ctrl_actions)
|
|
|
|
if hmi_ctrl_actions:
|
|
for action in hmi_ctrl_actions:
|
|
print(f" REPAIRED: {action}")
|
|
else:
|
|
print(" No HMI controller register issues found")
|
|
|
|
# Step 3e: Repair target device registers (actuators, sensors, PLCs)
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 3e: Repairing target device registers")
|
|
print("=" * 60)
|
|
|
|
enriched_dict, target_reg_actions = repair_target_device_registers(enriched_dict)
|
|
all_repair_actions.extend(target_reg_actions)
|
|
|
|
if target_reg_actions:
|
|
for action in target_reg_actions:
|
|
print(f" REPAIRED: {action}")
|
|
else:
|
|
print(" No target device register issues found")
|
|
|
|
# Re-validate after all repairs
|
|
if all_repair_actions:
|
|
print()
|
|
print(" Re-validating after repairs...")
|
|
try:
|
|
enriched_config = Config.model_validate(enriched_dict)
|
|
print(" Post-repair validation: OK")
|
|
except Exception as e:
|
|
raise SystemExit(f"ERROR: Repair produced invalid config:\n{e}")
|
|
|
|
# =========================================================================
|
|
# Step 4: Semantic validation (P0 checks)
|
|
# =========================================================================
|
|
if not args.skip_semantic:
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 4: Semantic validation (P0 checks)")
|
|
print("=" * 60)
|
|
|
|
errors = validate_all_semantics(enriched_config)
|
|
|
|
if errors:
|
|
if args.json_errors:
|
|
# Output errors as JSON for programmatic consumption
|
|
error_list = [{"entity": err.entity, "message": err.message} for err in errors]
|
|
print(json.dumps({"semantic_errors": error_list}, indent=2))
|
|
sys.exit(2) # Exit code 2 = semantic validation failure
|
|
else:
|
|
print()
|
|
print("SEMANTIC VALIDATION ERRORS:")
|
|
for err in errors:
|
|
print(f" - {err}")
|
|
print()
|
|
raise SystemExit(
|
|
f"ERROR: Semantic validation failed with {len(errors)} error(s). "
|
|
f"Fix the configuration and retry, or use --repair to auto-fix orphans."
|
|
)
|
|
else:
|
|
print(" HMI monitors/controllers: OK")
|
|
print(" PLC monitors/controllers: OK")
|
|
print(" Orphan devices: OK")
|
|
print(" Boolean type rules: OK")
|
|
print(" PLC local register coherence: OK")
|
|
else:
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 4: Semantic validation (SKIPPED)")
|
|
print("=" * 60)
|
|
|
|
# =========================================================================
|
|
# Step 5: Write final configuration
|
|
# =========================================================================
|
|
print()
|
|
print("=" * 60)
|
|
print("Step 5: Writing configuration.json")
|
|
print("=" * 60)
|
|
|
|
final_dict = config_to_dict(enriched_config)
|
|
output_path.write_text(
|
|
json.dumps(final_dict, indent=2, ensure_ascii=False),
|
|
encoding="utf-8"
|
|
)
|
|
print(f" Written: {output_path}")
|
|
|
|
# =========================================================================
|
|
# Summary
|
|
# =========================================================================
|
|
print()
|
|
print("#" * 60)
|
|
print("# SUCCESS: Configuration built and validated")
|
|
print("#" * 60)
|
|
print()
|
|
print(f"Output: {output_path}")
|
|
print()
|
|
|
|
# Summarize enrichment
|
|
for plc in enriched_config.plcs:
|
|
n_conn = len(plc.outbound_connections)
|
|
n_mon = len(plc.monitors)
|
|
n_ctrl = len(plc.controllers)
|
|
print(f" {plc.name}: {n_conn} connections, {n_mon} monitors, {n_ctrl} controllers")
|
|
|
|
for hmi in enriched_config.hmis:
|
|
n_mon = len(hmi.monitors)
|
|
n_ctrl = len(hmi.controllers)
|
|
print(f" {hmi.name}: {n_mon} monitors, {n_ctrl} controllers")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|