From 99f42fe651d6d08bac9f37582e334c0613ce11c4 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Sun, 19 Jul 2026 17:44:00 +0300 Subject: [PATCH 01/34] Implements the data model and versioning layer for Event Orchestration. Part of #16 Closes #17 --- ...260719090000_event_orchestration_models.py | 39 + app/modules/db/models.py | 311 ++++++++ app/modules/db/orchestrations_repo.py | 735 ++++++++++++++++++ app/services/orchestration/__init__.py | 1 + app/services/orchestration/permissions.py | 53 ++ .../test_orchestration_migration.py | 28 + .../test_orchestration_versioning.py | 284 +++++++ 7 files changed, 1451 insertions(+) create mode 100644 app/migrations/20260719090000_event_orchestration_models.py create mode 100644 app/modules/db/orchestrations_repo.py create mode 100644 app/services/orchestration/__init__.py create mode 100644 app/services/orchestration/permissions.py create mode 100644 tests/orchestration/test_orchestration_migration.py create mode 100644 tests/orchestration/test_orchestration_versioning.py diff --git a/app/migrations/20260719090000_event_orchestration_models.py b/app/migrations/20260719090000_event_orchestration_models.py new file mode 100644 index 0000000..c397e74 --- /dev/null +++ b/app/migrations/20260719090000_event_orchestration_models.py @@ -0,0 +1,39 @@ +"""Create Event Orchestration v1 control-plane tables.""" + +from app.db import init_database +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, +) + + +db = init_database() + + +def upgrade(): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + ], + safe=True, + ) + + +def downgrade(): + db.drop_tables( + [ + OrchestrationExecution, + OrchestrationIntakeToken, + EventOrchestrationRule, + EventOrchestrationVersion, + EventOrchestration, + ], + safe=True, + ) diff --git a/app/modules/db/models.py b/app/modules/db/models.py index 92e1817..794ebf2 100644 --- a/app/modules/db/models.py +++ b/app/modules/db/models.py @@ -2715,3 +2715,314 @@ class CalendarFeed(SoftDeleteModel): ) created_at = DateTimeField(default=datetime.utcnow) last_used_at = DateTimeField(null=True) + +# BEGIN EVENT ORCHESTRATION V1 MODELS + +EVENT_ORCHESTRATION_SCOPES = ("global", "service") +EVENT_ORCHESTRATION_MODES = ("active", "shadow", "disabled") +EVENT_ORCHESTRATION_VERSION_STATUSES = ("draft", "published", "archived") +EVENT_ORCHESTRATION_PROCESSING_MODES = ( + "continue", + "stop", + "evaluate_children", + "children_then_continue", +) + + +class EventOrchestration(SoftDeleteModel): + """A group-owned orchestration with one atomically selected active version.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="event_orchestrations", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + description = TextField(null=True) + scope = CharField(max_length=32, default="global", index=True) + service = ForeignKeyField( + Service, + backref="event_orchestrations", + null=True, + on_delete="SET NULL", + index=True, + ) + enabled = BooleanField(default=False, index=True) + mode = CharField(max_length=32, default="disabled", index=True) + # Kept as an integer to avoid a circular DDL dependency between the + # orchestration and orchestration-version tables. Repository code verifies + # that the selected version belongs to this orchestration. + active_version_id = IntegerField(null=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_event_orchestrations", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=datetime.utcnow, index=True) + updated_at = DateTimeField(default=datetime.utcnow) + + class Meta: + table_name = "event_orchestration" + indexes = ( + (("group", "name"), True), + (("group", "scope"), False), + (("group", "mode", "enabled"), False), + (("service", "enabled"), False), + ) + + def save(self, *args, **kwargs): + if self.scope not in EVENT_ORCHESTRATION_SCOPES: + raise ValueError("Invalid orchestration scope") + if self.mode not in EVENT_ORCHESTRATION_MODES: + raise ValueError("Invalid orchestration mode") + + if self.scope == "global" and self.service_id is not None: + raise ValueError("Global orchestration cannot reference a service") + if self.scope == "service" and self.service_id is None: + raise ValueError("Service-scoped orchestration requires a service") + + if self.service_id is not None: + service_group_id = ( + Service.select(Service.group) + .where(Service.id == self.service_id) + .scalar() + ) + if service_group_id is None: + raise ValueError("Referenced service does not exist") + if int(service_group_id) != int(self.group_id): + raise ValueError("Referenced service belongs to another group") + + self.updated_at = datetime.utcnow() + return super().save(*args, **kwargs) + + +class EventOrchestrationVersion(BaseModel): + """An immutable published orchestration definition.""" + + id = AutoField() + orchestration = ForeignKeyField( + EventOrchestration, + backref="versions", + on_delete="CASCADE", + index=True, + ) + version_number = IntegerField() + status = CharField(max_length=32, default="draft", index=True) + definition_hash = CharField(max_length=64, null=True, index=True) + definition_json = JSONTextField(default=dict) + comment = TextField(null=True) + created_by = ForeignKeyField( + User, + backref="created_event_orchestration_versions", + null=True, + on_delete="SET NULL", + ) + published_by = ForeignKeyField( + User, + backref="published_event_orchestration_versions", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=datetime.utcnow, index=True) + updated_at = DateTimeField(default=datetime.utcnow) + published_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "event_orchestration_version" + indexes = ( + (("orchestration", "version_number"), True), + (("orchestration", "status"), False), + ) + + def save(self, *args, **kwargs): + if self.status not in EVENT_ORCHESTRATION_VERSION_STATUSES: + raise ValueError("Invalid orchestration version status") + + if self.id is None and self.status != "draft": + raise ValueError("New orchestration versions must start as drafts") + + if self.id is not None: + current = ( + EventOrchestrationVersion.select( + EventOrchestrationVersion.status, + ) + .where(EventOrchestrationVersion.id == self.id) + .dicts() + .get() + ) + dirty = {field.name for field in self.dirty_fields} + current_status = current["status"] + + # A published version may only be archived. All other changes are + # rejected. Repository publication uses an atomic UPDATE for this + # narrowly allowed state transition. + archive_only = ( + current_status == "published" + and self.status == "archived" + and dirty.issubset({"status", "updated_at"}) + ) + if current_status in ("published", "archived") and not archive_only: + raise ValueError("Published orchestration versions are immutable") + + self.updated_at = datetime.utcnow() + return super().save(*args, **kwargs) + + def delete_instance(self, *args, **kwargs): + if self.status != "draft": + raise ValueError("Published orchestration versions cannot be deleted") + return super().delete_instance(*args, **kwargs) + + +class EventOrchestrationRule(BaseModel): + """A deterministic ordered rule tree belonging to one draft/version.""" + + id = AutoField() + version = ForeignKeyField( + EventOrchestrationVersion, + backref="rules", + on_delete="CASCADE", + index=True, + ) + parent_rule = ForeignKeyField( + "self", + backref="children", + null=True, + on_delete="CASCADE", + index=True, + ) + position = IntegerField(default=0) + name = CharField(max_length=255) + description = TextField(null=True) + enabled = BooleanField(default=True, index=True) + condition_tree_json = JSONTextField(default=dict) + actions_json = JSONTextField(default=list) + processing_mode = CharField(max_length=32, default="continue") + created_at = DateTimeField(default=datetime.utcnow, index=True) + updated_at = DateTimeField(default=datetime.utcnow) + + class Meta: + table_name = "event_orchestration_rule" + indexes = ( + (("version", "parent_rule", "position"), True), + (("version", "enabled"), False), + ) + + def _assert_draft(self): + version_status = ( + EventOrchestrationVersion.select(EventOrchestrationVersion.status) + .where(EventOrchestrationVersion.id == self.version_id) + .scalar() + ) + if version_status != "draft": + raise ValueError("Rules of published orchestration versions are immutable") + + def save(self, *args, **kwargs): + if self.processing_mode not in EVENT_ORCHESTRATION_PROCESSING_MODES: + raise ValueError("Invalid orchestration rule processing mode") + if self.version_id is None: + raise ValueError("Orchestration rule requires a version") + self._assert_draft() + + if self.parent_rule_id is not None: + parent_version_id = ( + EventOrchestrationRule.select(EventOrchestrationRule.version) + .where(EventOrchestrationRule.id == self.parent_rule_id) + .scalar() + ) + if parent_version_id is None: + raise ValueError("Parent orchestration rule does not exist") + if int(parent_version_id) != int(self.version_id): + raise ValueError("Parent rule belongs to another version") + + self.updated_at = datetime.utcnow() + return super().save(*args, **kwargs) + + def delete_instance(self, *args, **kwargs): + self._assert_draft() + return super().delete_instance(*args, **kwargs) + + +class OrchestrationIntakeToken(BaseModel): + """Hashed intake credential scoped to a single orchestration.""" + + id = AutoField() + orchestration = ForeignKeyField( + EventOrchestration, + backref="intake_tokens", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + token_hash = CharField(max_length=255, unique=True, index=True) + token_prefix = CharField(max_length=24, null=True, index=True) + enabled = BooleanField(default=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_orchestration_intake_tokens", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=datetime.utcnow, index=True) + last_used_at = DateTimeField(null=True) + revoked_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "orchestration_intake_token" + indexes = ( + (("orchestration", "name"), True), + (("orchestration", "enabled"), False), + ) + + +class OrchestrationExecution(BaseModel): + """Immutable execution audit record for explainability and retention.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="orchestration_executions", + on_delete="CASCADE", + index=True, + ) + orchestration = ForeignKeyField( + EventOrchestration, + backref="executions", + on_delete="CASCADE", + index=True, + ) + version = ForeignKeyField( + EventOrchestrationVersion, + backref="executions", + on_delete="RESTRICT", + index=True, + ) + source = CharField(max_length=128, null=True, index=True) + integration_name = CharField(max_length=255, null=True) + event_fingerprint = CharField(max_length=255, null=True, index=True) + disposition = CharField(max_length=64, null=True, index=True) + matched_rule_count = IntegerField(default=0) + duration_ms = IntegerField(null=True) + trace_json = JSONTextField(default=dict) + # Deliberately retained as scalar IDs: execution history must remain + # readable even if an alert or alert group is later removed. + alert_id = IntegerField(null=True, index=True) + alert_group_id = IntegerField(null=True, index=True) + created_at = DateTimeField(default=datetime.utcnow, index=True) + expires_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "orchestration_execution" + indexes = ( + (("group", "created_at"), False), + (("orchestration", "created_at"), False), + (("version", "created_at"), False), + (("event_fingerprint", "created_at"), False), + ) + + +# END EVENT ORCHESTRATION V1 MODELS diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py new file mode 100644 index 0000000..0efc6f6 --- /dev/null +++ b/app/modules/db/orchestrations_repo.py @@ -0,0 +1,735 @@ +import hashlib +import json +import secrets +from datetime import datetime +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from peewee import IntegrityError, fn + +from app.modules.db import models as db_models +from app.db import database_proxy +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationIntakeToken, + Service, +) +from app.services.integrations.auth import hash_token + + +VALID_SCOPES = {"global", "service"} +VALID_MODES = {"active", "shadow", "disabled"} +VALID_VERSION_STATUSES = {"draft", "published", "archived"} +VALID_PROCESSING_MODES = { + "continue", + "stop", + "evaluate_children", + "children_then_continue", +} + + +class OrchestrationError(ValueError): + """Base error raised for invalid orchestration state transitions.""" + + +class OrchestrationNotFound(OrchestrationError): + pass + + +class OrchestrationConflict(OrchestrationError): + pass + + +class OrchestrationValidationError(OrchestrationError): + def __init__(self, errors: Sequence[str], warnings: Optional[Sequence[str]] = None): + self.errors = list(errors) + self.warnings = list(warnings or []) + super().__init__("; ".join(self.errors) or "Invalid orchestration definition") + + +_REFERENCE_ACTIONS: Dict[str, Tuple[str, Tuple[str, ...]]] = { + "set_team": ("Team", ("team_id", "value")), + "set_route": ("AlertRoute", ("route_id", "value")), + "set_service": ("Service", ("service_id", "value")), + "set_escalation_policy": ( + "EscalationPolicy", + ("escalation_policy_id", "policy_id", "value"), + ), + "set_notification_policy": ( + "NotificationPolicy", + ("notification_policy_id", "policy_id", "value"), + ), + "set_priority_policy": ( + "PriorityPolicy", + ("priority_policy_id", "policy_id", "value"), + ), +} + + +def canonical_json(value: Any) -> str: + """Return the stable JSON representation used for content hashing.""" + + return json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + + +def definition_hash(definition: Dict[str, Any]) -> str: + return hashlib.sha256(canonical_json(definition).encode("utf-8")).hexdigest() + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def _get_orchestration(orchestration_id: int) -> EventOrchestration: + orchestration = EventOrchestration.get_or_none(EventOrchestration.id == orchestration_id) + if ( + orchestration is None + or bool(orchestration.deleted) + or orchestration.deleted_at is not None + ): + raise OrchestrationNotFound("Orchestration not found") + return orchestration + + +def _get_version(version_id: int) -> EventOrchestrationVersion: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == version_id + ) + if version is None: + raise OrchestrationNotFound("Orchestration version not found") + return version + + +def _for_update(query): + """Use row locking where supported; SQLite is protected by its transaction.""" + + database = getattr(database_proxy, "obj", None) + if database is not None and database.__class__.__name__ != "SqliteDatabase": + return query.for_update() + return query + + +def _locked_version(version_id: int) -> EventOrchestrationVersion: + version = _for_update( + EventOrchestrationVersion.select().where( + EventOrchestrationVersion.id == version_id + ) + ).first() + if version is None: + raise OrchestrationNotFound("Orchestration version not found") + return version + + +def _locked_orchestration(orchestration_id: int) -> EventOrchestration: + query = EventOrchestration.select().where( + (EventOrchestration.id == orchestration_id) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + orchestration = _for_update(query).first() + if orchestration is None: + raise OrchestrationNotFound("Orchestration not found") + return orchestration + + +def _next_version_number(orchestration_id: int) -> int: + maximum = ( + EventOrchestrationVersion.select( + fn.MAX(EventOrchestrationVersion.version_number) + ) + .where(EventOrchestrationVersion.orchestration == orchestration_id) + .scalar() + ) + return int(maximum or 0) + 1 + + +def _assert_draft(version: EventOrchestrationVersion) -> None: + if version.status != "draft": + raise OrchestrationConflict("Only draft versions can be edited") + + +def _service_group_id(service_id: int) -> Optional[int]: + value = Service.select(Service.group).where(Service.id == service_id).scalar() + return int(value) if value is not None else None + + +def create_orchestration( + *, + group_id: int, + name: str, + scope: str = "global", + service_id: Optional[int] = None, + description: Optional[str] = None, + created_by_id: Optional[int] = None, +) -> EventOrchestration: + name = (name or "").strip() + if not name: + raise OrchestrationValidationError(["name is required"]) + if scope not in VALID_SCOPES: + raise OrchestrationValidationError(["scope must be global or service"]) + if scope == "global" and service_id is not None: + raise OrchestrationValidationError( + ["global orchestration cannot reference a service"] + ) + if scope == "service": + if service_id is None: + raise OrchestrationValidationError( + ["service-scoped orchestration requires service_id"] + ) + if _service_group_id(service_id) != int(group_id): + raise OrchestrationValidationError( + ["referenced service belongs to another group"] + ) + + try: + return EventOrchestration.create( + group=group_id, + name=name, + description=description, + scope=scope, + service=service_id, + enabled=False, + mode="disabled", + created_by=created_by_id, + ) + except IntegrityError as exc: + raise OrchestrationConflict( + "An orchestration with this name already exists in the group" + ) from exc + + +def get_draft(orchestration_id: int) -> Optional[EventOrchestrationVersion]: + return ( + EventOrchestrationVersion.select() + .where( + (EventOrchestrationVersion.orchestration == orchestration_id) + & (EventOrchestrationVersion.status == "draft") + ) + .order_by(EventOrchestrationVersion.version_number.desc()) + .first() + ) + + +def _serialize_rule(rule: EventOrchestrationRule) -> Dict[str, Any]: + children = list( + EventOrchestrationRule.select() + .where(EventOrchestrationRule.parent_rule == rule.id) + .order_by( + EventOrchestrationRule.position.asc(), + EventOrchestrationRule.id.asc(), + ) + ) + return { + "name": rule.name, + "description": rule.description, + "enabled": bool(rule.enabled), + "condition_tree": rule.condition_tree_json or {}, + "actions": rule.actions_json or [], + "processing_mode": rule.processing_mode, + "children": [_serialize_rule(child) for child in children], + } + + +def export_version(version_id: int) -> Dict[str, Any]: + version = _get_version(version_id) + orchestration = version.orchestration + roots = list( + EventOrchestrationRule.select() + .where( + (EventOrchestrationRule.version == version.id) + & EventOrchestrationRule.parent_rule.is_null(True) + ) + .order_by( + EventOrchestrationRule.position.asc(), + EventOrchestrationRule.id.asc(), + ) + ) + return { + "schema_version": 1, + "scope": orchestration.scope, + "service_id": orchestration.service_id, + "rules": [_serialize_rule(rule) for rule in roots], + } + + +def _create_rule_tree( + version: EventOrchestrationVersion, + rules: Iterable[Dict[str, Any]], + parent_rule_id: Optional[int] = None, +) -> None: + for position, raw_rule in enumerate(rules): + if not isinstance(raw_rule, dict): + raise OrchestrationValidationError(["every rule must be an object"]) + + children = raw_rule.get("children") or [] + if not isinstance(children, list): + raise OrchestrationValidationError(["rule children must be a list"]) + + rule = EventOrchestrationRule.create( + version=version.id, + parent_rule=parent_rule_id, + position=position, + name=(raw_rule.get("name") or "").strip() or f"Rule {position + 1}", + description=raw_rule.get("description"), + enabled=bool(raw_rule.get("enabled", True)), + condition_tree_json=raw_rule.get("condition_tree") or {}, + actions_json=raw_rule.get("actions") or [], + processing_mode=raw_rule.get("processing_mode") or "continue", + ) + _create_rule_tree(version, children, rule.id) + + +def replace_draft_rules( + version_id: int, + rules: Sequence[Dict[str, Any]], +) -> EventOrchestrationVersion: + if not isinstance(rules, (list, tuple)): + raise OrchestrationValidationError(["rules must be a list"]) + + with database_proxy.atomic(): + version = _locked_version(version_id) + _assert_draft(version) + EventOrchestrationRule.delete().where( + EventOrchestrationRule.version == version.id + ).execute() + _create_rule_tree(version, rules) + EventOrchestrationVersion.update(updated_at=_utcnow()).where( + EventOrchestrationVersion.id == version.id + ).execute() + return _get_version(version_id) + + +def _clone_version_rules( + source_version_id: int, + target_version: EventOrchestrationVersion, +) -> None: + source_definition = export_version(source_version_id) + _create_rule_tree(target_version, source_definition.get("rules") or []) + + +def get_or_create_draft( + orchestration_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + draft = get_draft(orchestration.id) + if draft is not None: + return draft + + try: + draft = EventOrchestrationVersion.create( + orchestration=orchestration.id, + version_number=_next_version_number(orchestration.id), + status="draft", + definition_json={}, + comment=comment, + created_by=actor_id, + ) + except IntegrityError as exc: + # A concurrent creator may have won the unique version-number race. + draft = get_draft(orchestration.id) + if draft is None: + raise OrchestrationConflict( + "Could not allocate an orchestration draft version" + ) from exc + return draft + + if orchestration.active_version_id is not None: + active = _get_version(orchestration.active_version_id) + if active.orchestration_id != orchestration.id: + raise OrchestrationConflict( + "Active version does not belong to the orchestration" + ) + _clone_version_rules(active.id, draft) + + return draft + + +def _walk_json(value: Any): + yield value + if isinstance(value, dict): + for child in value.values(): + yield from _walk_json(child) + elif isinstance(value, list): + for child in value: + yield from _walk_json(child) + + +def _extract_reference_id(action: Dict[str, Any], keys: Sequence[str]) -> Any: + for key in keys: + if key in action and action[key] not in (None, ""): + return action[key] + params = action.get("params") + if isinstance(params, dict): + for key in keys: + if key in params and params[key] not in (None, ""): + return params[key] + return None + + +def _entity_group_id(entity: Any) -> Optional[int]: + direct = getattr(entity, "group_id", None) + if direct is not None: + return int(direct) + + team_id = getattr(entity, "team_id", None) + if team_id is not None: + team_model = getattr(db_models, "Team", None) + if team_model is None: + return None + value = team_model.select(team_model.group).where(team_model.id == team_id).scalar() + return int(value) if value is not None else None + + service_id = getattr(entity, "service_id", None) + if service_id is not None: + return _service_group_id(service_id) + + return None + + +def _validate_action_references( + actions: Any, + orchestration_group_id: int, + rule_path: str, +) -> List[str]: + errors: List[str] = [] + for node in _walk_json(actions): + if not isinstance(node, dict): + continue + action_type = node.get("type") or node.get("action") + reference_spec = _REFERENCE_ACTIONS.get(action_type) + if reference_spec is None: + continue + + model_name, id_keys = reference_spec + reference_id = _extract_reference_id(node, id_keys) + if reference_id in (None, ""): + errors.append(f"{rule_path}: {action_type} requires a reference id") + continue + + model = getattr(db_models, model_name, None) + if model is None: + errors.append( + f"{rule_path}: action {action_type} references unsupported model {model_name}" + ) + continue + + try: + reference_id = int(reference_id) + except (TypeError, ValueError): + errors.append(f"{rule_path}: {action_type} reference id must be an integer") + continue + + entity = model.get_or_none(model.id == reference_id) + if entity is None: + errors.append(f"{rule_path}: referenced {model_name} does not exist") + continue + + entity_group_id = _entity_group_id(entity) + if entity_group_id is None: + errors.append( + f"{rule_path}: could not determine group for referenced {model_name}" + ) + elif entity_group_id != int(orchestration_group_id): + errors.append( + f"{rule_path}: referenced {model_name} belongs to another group" + ) + + return errors + + +def validate_version(version_id: int) -> Dict[str, Any]: + version = _get_version(version_id) + orchestration = version.orchestration + errors: List[str] = [] + warnings: List[str] = [] + + if version.status not in VALID_VERSION_STATUSES: + errors.append("invalid version status") + if orchestration.scope not in VALID_SCOPES: + errors.append("invalid orchestration scope") + if orchestration.mode not in VALID_MODES: + errors.append("invalid orchestration mode") + + if orchestration.scope == "global" and orchestration.service_id is not None: + errors.append("global orchestration cannot reference a service") + if orchestration.scope == "service": + if orchestration.service_id is None: + errors.append("service-scoped orchestration requires a service") + elif _service_group_id(orchestration.service_id) != orchestration.group_id: + errors.append("service-scoped orchestration references another group") + + rules = list( + EventOrchestrationRule.select() + .where(EventOrchestrationRule.version == version.id) + .order_by(EventOrchestrationRule.id.asc()) + ) + if not rules: + warnings.append("orchestration version has no rules") + + positions: Dict[Tuple[Optional[int], int], int] = {} + for rule in rules: + path = f"rule {rule.id} ({rule.name})" + if rule.processing_mode not in VALID_PROCESSING_MODES: + errors.append(f"{path}: invalid processing_mode") + if not isinstance(rule.condition_tree_json, dict): + errors.append(f"{path}: condition_tree must be an object") + if not isinstance(rule.actions_json, list): + errors.append(f"{path}: actions must be a list") + if rule.parent_rule_id is not None: + parent_version_id = ( + EventOrchestrationRule.select(EventOrchestrationRule.version) + .where(EventOrchestrationRule.id == rule.parent_rule_id) + .scalar() + ) + if parent_version_id != version.id: + errors.append(f"{path}: parent belongs to another version") + + position_key = (rule.parent_rule_id, rule.position) + positions[position_key] = positions.get(position_key, 0) + 1 + if positions[position_key] > 1: + errors.append(f"{path}: duplicate sibling position {rule.position}") + + errors.extend( + _validate_action_references( + rule.actions_json, + orchestration.group_id, + path, + ) + ) + + exported = export_version(version.id) + digest = definition_hash(exported) + return { + "valid": not errors, + "errors": errors, + "warnings": warnings, + "definition": exported, + "definition_hash": digest, + } + + +def _publish_version_locked( + orchestration: EventOrchestration, + draft: EventOrchestrationVersion, + *, + actor_id: Optional[int], + comment: Optional[str], +) -> EventOrchestrationVersion: + if draft.orchestration_id != orchestration.id: + raise OrchestrationConflict("Draft belongs to another orchestration") + _assert_draft(draft) + + validation = validate_version(draft.id) + if not validation["valid"]: + raise OrchestrationValidationError( + validation["errors"], + validation["warnings"], + ) + + now = _utcnow() + EventOrchestrationVersion.update( + status="archived", + updated_at=now, + ).where( + (EventOrchestrationVersion.orchestration == orchestration.id) + & (EventOrchestrationVersion.status == "published") + & (EventOrchestrationVersion.id != draft.id) + ).execute() + + update_values: Dict[str, Any] = { + "status": "published", + "definition_hash": validation["definition_hash"], + "definition_json": validation["definition"], + "published_by": actor_id, + "published_at": now, + "updated_at": now, + } + if comment is not None: + update_values["comment"] = comment + + updated = ( + EventOrchestrationVersion.update(**update_values) + .where( + (EventOrchestrationVersion.id == draft.id) + & (EventOrchestrationVersion.status == "draft") + ) + .execute() + ) + if updated != 1: + raise OrchestrationConflict("Draft changed while it was being published") + + EventOrchestration.update( + active_version_id=draft.id, + updated_at=now, + ).where(EventOrchestration.id == orchestration.id).execute() + + return _get_version(draft.id) + + +def publish_draft( + orchestration_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + """Validate and atomically activate the current draft.""" + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + draft_query = EventOrchestrationVersion.select().where( + (EventOrchestrationVersion.orchestration == orchestration.id) + & (EventOrchestrationVersion.status == "draft") + ).order_by(EventOrchestrationVersion.version_number.desc()) + draft = _for_update(draft_query).first() + if draft is None: + raise OrchestrationConflict("Orchestration has no draft to publish") + return _publish_version_locked( + orchestration, + draft, + actor_id=actor_id, + comment=comment, + ) + + +def rollback_to_version( + orchestration_id: int, + source_version_id: int, + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + """Publish a new immutable version copied from an earlier version.""" + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + source = _get_version(source_version_id) + if source.orchestration_id != orchestration.id: + raise OrchestrationValidationError( + ["rollback source belongs to another orchestration"] + ) + if source.status not in {"published", "archived"}: + raise OrchestrationValidationError( + ["rollback source must be published or archived"] + ) + if get_draft(orchestration.id) is not None: + raise OrchestrationConflict( + "Archive or publish the existing draft before rollback" + ) + + draft = EventOrchestrationVersion.create( + orchestration=orchestration.id, + version_number=_next_version_number(orchestration.id), + status="draft", + definition_json={}, + comment=comment or f"Rollback to version {source.version_number}", + created_by=actor_id, + ) + _clone_version_rules(source.id, draft) + return _publish_version_locked( + orchestration, + draft, + actor_id=actor_id, + comment=draft.comment, + ) + + +def archive_draft(version_id: int) -> EventOrchestrationVersion: + with database_proxy.atomic(): + version = _locked_version(version_id) + _assert_draft(version) + EventOrchestrationRule.delete().where( + EventOrchestrationRule.version == version.id + ).execute() + EventOrchestrationVersion.update( + status="archived", + updated_at=_utcnow(), + ).where( + (EventOrchestrationVersion.id == version.id) + & (EventOrchestrationVersion.status == "draft") + ).execute() + return _get_version(version_id) + + +def archive_orchestration(orchestration_id: int) -> EventOrchestration: + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + now = _utcnow() + EventOrchestration.update( + enabled=False, + mode="disabled", + deleted=True, + deleted_at=now, + updated_at=now, + ).where(EventOrchestration.id == orchestration.id).execute() + OrchestrationIntakeToken.update( + enabled=False, + revoked_at=now, + ).where( + (OrchestrationIntakeToken.orchestration == orchestration.id) + & (OrchestrationIntakeToken.enabled == True) # noqa: E712 + ).execute() + return EventOrchestration.get_by_id(orchestration_id) + + +def create_intake_token( + orchestration_id: int, + *, + name: str, + actor_id: Optional[int] = None, +) -> Tuple[OrchestrationIntakeToken, str]: + """Create a token and return plaintext once together with the DB record.""" + + name = (name or "").strip() + if not name: + raise OrchestrationValidationError(["token name is required"]) + + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + plaintext = secrets.token_urlsafe(32) + record = OrchestrationIntakeToken.create( + orchestration=orchestration.id, + name=name, + token_hash=hash_token(plaintext), + token_prefix=plaintext[:12], + enabled=True, + created_by=actor_id, + ) + return record, plaintext + + +def authenticate_intake_token(plaintext: str) -> Optional[OrchestrationIntakeToken]: + if not plaintext: + return None + token = OrchestrationIntakeToken.get_or_none( + (OrchestrationIntakeToken.token_hash == hash_token(plaintext)) + & (OrchestrationIntakeToken.enabled == True) # noqa: E712 + & OrchestrationIntakeToken.revoked_at.is_null(True) + ) + if token is None: + return None + OrchestrationIntakeToken.update(last_used_at=_utcnow()).where( + OrchestrationIntakeToken.id == token.id + ).execute() + return token + + +def revoke_intake_token(token_id: int) -> OrchestrationIntakeToken: + now = _utcnow() + updated = OrchestrationIntakeToken.update( + enabled=False, + revoked_at=now, + ).where(OrchestrationIntakeToken.id == token_id).execute() + if updated != 1: + raise OrchestrationNotFound("Orchestration intake token not found") + return OrchestrationIntakeToken.get_by_id(token_id) diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py new file mode 100644 index 0000000..f6e885c --- /dev/null +++ b/app/services/orchestration/__init__.py @@ -0,0 +1 @@ +"""Event Orchestration control-plane services.""" diff --git a/app/services/orchestration/permissions.py b/app/services/orchestration/permissions.py new file mode 100644 index 0000000..ca975f7 --- /dev/null +++ b/app/services/orchestration/permissions.py @@ -0,0 +1,53 @@ +from typing import Dict, FrozenSet + +from app.modules.db.models import UserGroup + + +VIEW = "orchestration.view" +CREATE = "orchestration.create" +EDIT = "orchestration.edit" +SIMULATE = "orchestration.simulate" +PUBLISH = "orchestration.publish" +DELETE = "orchestration.delete" +MANAGE_TOKENS = "orchestration.manage_tokens" +VIEW_EXECUTIONS = "orchestration.view_executions" +REPLAY = "orchestration.replay" +MANAGE_ACTIONS = "orchestration.manage_actions" + +ALL_PERMISSIONS: FrozenSet[str] = frozenset( + { + VIEW, + CREATE, + EDIT, + SIMULATE, + PUBLISH, + DELETE, + MANAGE_TOKENS, + VIEW_EXECUTIONS, + REPLAY, + MANAGE_ACTIONS, + } +) + +GROUP_ROLE_PERMISSIONS: Dict[str, FrozenSet[str]] = { + "viewer": frozenset({VIEW, VIEW_EXECUTIONS}), + "editor": frozenset({VIEW, CREATE, EDIT, SIMULATE, VIEW_EXECUTIONS, REPLAY}), + # user_admin is intentionally not treated as an orchestration publisher. + "user_admin": frozenset({VIEW, VIEW_EXECUTIONS}), +} + + +def has_orchestration_permission(user, group_id: int, permission: str) -> bool: + if permission not in ALL_PERMISSIONS: + return False + if bool(getattr(user, "is_admin", False)): + return True + + membership = UserGroup.get_or_none( + (UserGroup.user == user.id) + & (UserGroup.group == group_id) + & (UserGroup.active == True) # noqa: E712 + ) + if membership is None: + return False + return permission in GROUP_ROLE_PERMISSIONS.get(membership.role, frozenset()) diff --git a/tests/orchestration/test_orchestration_migration.py b/tests/orchestration/test_orchestration_migration.py new file mode 100644 index 0000000..ddc6570 --- /dev/null +++ b/tests/orchestration/test_orchestration_migration.py @@ -0,0 +1,28 @@ +import os + +from app.modules.db.migrations import get_migrations_dir, load_migration_module + + +TABLES = { + "event_orchestration", + "event_orchestration_version", + "event_orchestration_rule", + "orchestration_intake_token", + "orchestration_execution", +} + + +def test_event_orchestration_migration_upgrade_and_downgrade(db): + path = os.path.join( + get_migrations_dir(), + "20260719090000_event_orchestration_models.py", + ) + upgrade, downgrade = load_migration_module(path) + + upgrade() + assert TABLES.issubset(set(db.get_tables())) + + downgrade() + assert TABLES.isdisjoint(set(db.get_tables())) + + upgrade() diff --git a/tests/orchestration/test_orchestration_versioning.py b/tests/orchestration/test_orchestration_versioning.py new file mode 100644 index 0000000..5f8339a --- /dev/null +++ b/tests/orchestration/test_orchestration_versioning.py @@ -0,0 +1,284 @@ +import pytest + +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, +) +from app.modules.db.orchestrations_repo import ( + OrchestrationValidationError, + canonical_json, + create_intake_token, + create_orchestration, + definition_hash, + get_or_create_draft, + publish_draft, + replace_draft_rules, + revoke_intake_token, + rollback_to_version, + validate_version, +) +from tests.factories import create_group, create_service, create_team, create_user + + +@pytest.fixture(autouse=True) +def orchestration_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + ], + safe=True, + ) + yield + + +def _fixture(): + group = create_group() + user = create_user(group=group) + team = create_team(group) + service = create_service(team, name="API", slug="api") + orchestration = create_orchestration( + group_id=group.id, + name="Default orchestration", + created_by_id=user.id, + ) + return group, user, team, service, orchestration + + +def _rules(service_id=None): + actions = [{"type": "set_severity", "value": "critical"}] + if service_id is not None: + actions.append({"type": "set_service", "service_id": service_id}) + return [ + { + "name": "Critical production events", + "condition_tree": { + "all": [ + {"field": "labels.environment", "operator": "eq", "value": "prod"}, + {"field": "severity", "operator": "eq", "value": "critical"}, + ] + }, + "actions": actions, + "processing_mode": "evaluate_children", + "children": [ + { + "name": "Database child", + "condition_tree": { + "field": "labels.component", + "operator": "eq", + "value": "database", + }, + "actions": [{"type": "add_label", "key": "tier", "value": "data"}], + "processing_mode": "stop", + } + ], + } + ] + + +def test_canonical_json_and_hash_are_deterministic(): + left = {"b": 2, "a": {"z": 3, "x": 1}} + right = {"a": {"x": 1, "z": 3}, "b": 2} + + assert canonical_json(left) == canonical_json(right) + assert definition_hash(left) == definition_hash(right) + + +def test_publish_selects_active_version_and_makes_it_immutable(db): + _, user, _, service, orchestration = _fixture() + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(draft.id, _rules(service.id)) + + published = publish_draft(orchestration.id, actor_id=user.id) + orchestration = EventOrchestration.get_by_id(orchestration.id) + + assert published.status == "published" + assert orchestration.active_version_id == published.id + assert published.definition_hash + assert published.definition_json["rules"][0]["children"][0]["name"] == "Database child" + + published.comment = "must not change" + with pytest.raises(ValueError, match="immutable"): + published.save() + + rule = EventOrchestrationRule.get( + EventOrchestrationRule.version == published.id + ) + rule.name = "must not change" + with pytest.raises(ValueError, match="immutable"): + rule.save() + + +def test_second_publish_archives_previous_version(db): + _, user, _, service, orchestration = _fixture() + first_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(first_draft.id, _rules(service.id)) + first = publish_draft(orchestration.id, actor_id=user.id) + + second_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + cloned_rules = _rules(service.id) + cloned_rules[0]["name"] = "Changed draft" + replace_draft_rules(second_draft.id, cloned_rules) + second = publish_draft(orchestration.id, actor_id=user.id) + + first = EventOrchestrationVersion.get_by_id(first.id) + assert first.status == "archived" + assert second.status == "published" + assert second.version_number == first.version_number + 1 + assert EventOrchestration.get_by_id(orchestration.id).active_version_id == second.id + + +def test_rollback_creates_new_version_without_mutating_history(db): + _, user, _, service, orchestration = _fixture() + first_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(first_draft.id, _rules(service.id)) + first = publish_draft(orchestration.id, actor_id=user.id) + + second_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + changed = _rules(service.id) + changed[0]["name"] = "Second definition" + replace_draft_rules(second_draft.id, changed) + second = publish_draft(orchestration.id, actor_id=user.id) + + rolled_back = rollback_to_version( + orchestration.id, + first.id, + actor_id=user.id, + ) + + first = EventOrchestrationVersion.get_by_id(first.id) + second = EventOrchestrationVersion.get_by_id(second.id) + assert first.status == "archived" + assert second.status == "archived" + assert rolled_back.status == "published" + assert rolled_back.id not in {first.id, second.id} + assert rolled_back.version_number == second.version_number + 1 + assert rolled_back.definition_hash == first.definition_hash + + + +def _failing_update(*args, **kwargs): + class Query: + def where(self, *where_args, **where_kwargs): + return self + + def execute(self): + raise RuntimeError("forced activation failure") + + return Query() + + +def test_publish_is_atomic_when_active_pointer_update_fails(db, monkeypatch): + _, user, _, service, orchestration = _fixture() + first_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(first_draft.id, _rules(service.id)) + first = publish_draft(orchestration.id, actor_id=user.id) + + second_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + changed = _rules(service.id) + changed[0]["name"] = "Must remain a draft" + replace_draft_rules(second_draft.id, changed) + + monkeypatch.setattr(EventOrchestration, "update", _failing_update) + with pytest.raises(RuntimeError, match="forced activation failure"): + publish_draft(orchestration.id, actor_id=user.id) + + first = EventOrchestrationVersion.get_by_id(first.id) + second_draft = EventOrchestrationVersion.get_by_id(second_draft.id) + orchestration = EventOrchestration.get_by_id(orchestration.id) + assert first.status == "published" + assert second_draft.status == "draft" + assert orchestration.active_version_id == first.id + + +def test_rollback_is_atomic_when_active_pointer_update_fails(db, monkeypatch): + _, user, _, service, orchestration = _fixture() + first_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(first_draft.id, _rules(service.id)) + first = publish_draft(orchestration.id, actor_id=user.id) + + second_draft = get_or_create_draft(orchestration.id, actor_id=user.id) + changed = _rules(service.id) + changed[0]["name"] = "Current definition" + replace_draft_rules(second_draft.id, changed) + second = publish_draft(orchestration.id, actor_id=user.id) + version_count = EventOrchestrationVersion.select().where( + EventOrchestrationVersion.orchestration == orchestration.id + ).count() + + monkeypatch.setattr(EventOrchestration, "update", _failing_update) + with pytest.raises(RuntimeError, match="forced activation failure"): + rollback_to_version(orchestration.id, first.id, actor_id=user.id) + + assert ( + EventOrchestrationVersion.select() + .where(EventOrchestrationVersion.orchestration == orchestration.id) + .count() + == version_count + ) + assert EventOrchestrationVersion.get_by_id(second.id).status == "published" + assert EventOrchestration.get_by_id(orchestration.id).active_version_id == second.id + +def test_cross_group_service_scope_is_rejected(db): + group, user, _, _, _ = _fixture() + other_group = create_group() + other_team = create_team(other_group) + foreign_service = create_service(other_team, name="Foreign", slug="foreign") + + with pytest.raises(OrchestrationValidationError, match="another group"): + create_orchestration( + group_id=group.id, + name="Invalid service orchestration", + scope="service", + service_id=foreign_service.id, + created_by_id=user.id, + ) + + +def test_cross_group_action_reference_is_rejected(db): + _, user, _, _, orchestration = _fixture() + other_group = create_group() + other_team = create_team(other_group) + foreign_service = create_service(other_team, name="Foreign", slug="foreign") + + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(draft.id, _rules(foreign_service.id)) + + result = validate_version(draft.id) + assert result["valid"] is False + assert any("another group" in error for error in result["errors"]) + + with pytest.raises(OrchestrationValidationError, match="another group"): + publish_draft(orchestration.id, actor_id=user.id) + + +def test_new_orchestration_is_disabled_and_preserves_legacy_behavior(db): + _, _, _, _, orchestration = _fixture() + + assert orchestration.enabled is False + assert orchestration.mode == "disabled" + assert orchestration.active_version_id is None + + +def test_intake_token_is_returned_once_and_can_be_revoked(db): + _, user, _, _, orchestration = _fixture() + + token, plaintext = create_intake_token( + orchestration.id, + name="Migration token", + actor_id=user.id, + ) + + assert plaintext + assert plaintext not in token.token_hash + assert token.token_prefix == plaintext[:12] + + revoked = revoke_intake_token(token.id) + assert revoked.enabled is False + assert revoked.revoked_at is not None From 6b6eb0f352430f8ebec28cb30acc8b6928ef5c8e Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Mon, 20 Jul 2026 10:37:12 +0300 Subject: [PATCH 02/34] Implements condition evaluation, variable extraction, and safe templates. Part of #16 Closes #18 --- app/modules/db/orchestrations_repo.py | 16 + app/services/orchestration/__init__.py | 25 +- app/services/orchestration/conditions.py | 440 ++++++++++++++++++ app/services/orchestration/errors.py | 61 +++ app/services/orchestration/evaluator.py | 68 +++ app/services/orchestration/fields.py | 183 ++++++++ app/services/orchestration/limits.py | 18 + app/services/orchestration/regex.py | 91 ++++ app/services/orchestration/templates.py | 259 +++++++++++ app/services/orchestration/validation.py | 55 +++ app/services/orchestration/variables.py | 435 +++++++++++++++++ app/version.py | 2 +- .../test_orchestration_conditions.py | 144 ++++++ ...st_orchestration_publication_validation.py | 87 ++++ .../test_orchestration_regex_security.py | 52 +++ .../test_orchestration_rule_validation.py | 24 + .../test_orchestration_templates.py | 67 +++ .../test_orchestration_variables.py | 172 +++++++ 18 files changed, 2197 insertions(+), 2 deletions(-) create mode 100644 app/services/orchestration/conditions.py create mode 100644 app/services/orchestration/errors.py create mode 100644 app/services/orchestration/evaluator.py create mode 100644 app/services/orchestration/fields.py create mode 100644 app/services/orchestration/limits.py create mode 100644 app/services/orchestration/regex.py create mode 100644 app/services/orchestration/templates.py create mode 100644 app/services/orchestration/validation.py create mode 100644 app/services/orchestration/variables.py create mode 100644 tests/orchestration/test_orchestration_conditions.py create mode 100644 tests/orchestration/test_orchestration_publication_validation.py create mode 100644 tests/orchestration/test_orchestration_regex_security.py create mode 100644 tests/orchestration/test_orchestration_rule_validation.py create mode 100644 tests/orchestration/test_orchestration_templates.py create mode 100644 tests/orchestration/test_orchestration_variables.py diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index 0efc6f6..c6c83d8 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -16,6 +16,10 @@ Service, ) from app.services.integrations.auth import hash_token +from app.services.orchestration.validation import ( + issues_to_messages, + validate_rule_definition, +) VALID_SCOPES = {"global", "service"} @@ -486,6 +490,18 @@ def validate_version(version_id: int) -> Dict[str, Any]: errors.append(f"{path}: condition_tree must be an object") if not isinstance(rule.actions_json, list): errors.append(f"{path}: actions must be a list") + + # EVENT_ORCHESTRATION_WS2_VALIDATION + if isinstance(rule.condition_tree_json, dict) and isinstance( + rule.actions_json, list + ): + rule_validation = validate_rule_definition( + rule.condition_tree_json, + rule.actions_json, + path=path, + ) + errors.extend(issues_to_messages(rule_validation["errors"])) + warnings.extend(issues_to_messages(rule_validation["warnings"])) if rule.parent_rule_id is not None: parent_version_id = ( EventOrchestrationRule.select(EventOrchestrationRule.version) diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py index f6e885c..6b64a39 100644 --- a/app/services/orchestration/__init__.py +++ b/app/services/orchestration/__init__.py @@ -1 +1,24 @@ -"""Event Orchestration control-plane services.""" +"""Event Orchestration condition, extraction and template services.""" + +from .conditions import ConditionResult, evaluate_condition_tree, validate_condition_tree +from .evaluator import RuleEvaluationResult, evaluate_rule +from .fields import MISSING, build_context, resolve_field +from .templates import TemplateRenderResult, render_template, validate_template +from .variables import ExtractionResult, extract_variables, validate_extractors + +__all__ = [ + "ConditionResult", + "ExtractionResult", + "MISSING", + "RuleEvaluationResult", + "TemplateRenderResult", + "build_context", + "evaluate_condition_tree", + "evaluate_rule", + "extract_variables", + "render_template", + "resolve_field", + "validate_condition_tree", + "validate_extractors", + "validate_template", +] diff --git a/app/services/orchestration/conditions.py b/app/services/orchestration/conditions.py new file mode 100644 index 0000000..48f4691 --- /dev/null +++ b/app/services/orchestration/conditions.py @@ -0,0 +1,440 @@ +"""Deterministic nested condition-tree evaluation.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, field as dataclass_field +from decimal import Decimal, InvalidOperation +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +from .errors import ConditionValidationError, RegexSafetyError, ValidationIssue +from .fields import MISSING, resolve_field, validate_field_reference +from .limits import MAX_CONDITION_DEPTH, MAX_CONDITION_NODES, MAX_TRACE_VALUE_LENGTH +from .regex import bounded_regex_input, compile_safe_regex, validate_regex_pattern + + +LOGICAL_KEYS = frozenset({"all", "any", "none"}) +OPERATOR_ALIASES = { + "eq": "equals", + "ne": "not_equals", + "gt": "greater_than", + "lt": "less_than", + "gte": "greater_or_equal", + "lte": "less_or_equal", +} +SUPPORTED_OPERATORS = frozenset( + { + "equals", + "not_equals", + "contains", + "not_contains", + "starts_with", + "ends_with", + "regex", + "not_regex", + "in", + "not_in", + "exists", + "not_exists", + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + "is_true", + "is_false", + } +) + + +@dataclass(frozen=True) +class ConditionResult: + matched: bool + code: str + reason: str + path: str = "$" + node_type: str = "condition" + field: Optional[str] = None + operator: Optional[str] = None + expected: Any = MISSING + actual: Any = None + found: Optional[bool] = None + children: Tuple["ConditionResult", ...] = dataclass_field(default_factory=tuple) + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = { + "matched": self.matched, + "code": self.code, + "reason": self.reason, + "path": self.path, + "node_type": self.node_type, + } + if self.field is not None: + result["field"] = self.field + if self.operator is not None: + result["operator"] = self.operator + if self.found is not None: + result["found"] = self.found + if self.expected is not MISSING: + result["expected"] = _trace_value(self.expected) + if self.actual is not None or self.found is True: + result["actual"] = _trace_value(self.actual) + if self.children: + result["children"] = [child.to_dict() for child in self.children] + return result + + +def _trace_value(value: Any) -> Any: + if value is MISSING: + return None + if isinstance(value, str) and len(value) > MAX_TRACE_VALUE_LENGTH: + return value[:MAX_TRACE_VALUE_LENGTH] + "…" + if isinstance(value, list): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, tuple): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, dict): + items = list(value.items())[:32] + return {str(key): _trace_value(item) for key, item in items} + if isinstance(value, (str, int, float, bool)) or value is None: + return value + return "" + + +def normalize_operator(operator: Any) -> str: + if not isinstance(operator, str): + raise ConditionValidationError("condition operator must be a string") + normalized = OPERATOR_ALIASES.get(operator.strip(), operator.strip()) + if normalized not in SUPPORTED_OPERATORS: + raise ConditionValidationError(f"unsupported condition operator {operator!r}") + return normalized + + +def _decimal(value: Any) -> Decimal: + if isinstance(value, bool) or value is None: + raise InvalidOperation + if isinstance(value, Decimal): + result = value + elif isinstance(value, int): + result = Decimal(value) + elif isinstance(value, float): + if not math.isfinite(value): + raise InvalidOperation + result = Decimal(str(value)) + elif isinstance(value, str): + text = value.strip() + if not text: + raise InvalidOperation + result = Decimal(text) + else: + raise InvalidOperation + if not result.is_finite(): + raise InvalidOperation + return result + + +def _coerce_boolean(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, int) and value in (0, 1): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"true", "1", "yes", "on"}: + return True + if normalized in {"false", "0", "no", "off"}: + return False + raise ValueError("value is not deterministically boolean") + + +def _equal(left: Any, right: Any) -> bool: + if left is MISSING or right is MISSING: + return False + if isinstance(left, bool) or isinstance(right, bool): + return isinstance(left, bool) and isinstance(right, bool) and left == right + if isinstance(left, (int, float, Decimal)) or isinstance( + right, (int, float, Decimal) + ): + try: + return _decimal(left) == _decimal(right) + except InvalidOperation: + return False + if left is None or right is None: + return left is right + return type(left) is type(right) and left == right + + +def _contains(container: Any, expected: Any) -> bool: + if isinstance(container, str): + return isinstance(expected, str) and expected in container + if isinstance(container, Mapping): + return expected in container + if isinstance(container, Sequence) and not isinstance( + container, (str, bytes, bytearray) + ): + return any(_equal(item, expected) for item in container) + return False + + +def _in(actual: Any, expected: Any) -> bool: + if isinstance(expected, Mapping): + return actual in expected + if isinstance(expected, Sequence) and not isinstance( + expected, (str, bytes, bytearray) + ): + return any(_equal(actual, item) for item in expected) + return False + + +def _evaluate_operator(operator: str, actual: Any, expected: Any, found: bool) -> Tuple[bool, str]: + if operator == "exists": + return found, "field_exists" if found else "field_missing" + if operator == "not_exists": + return not found, "field_missing" if not found else "field_exists" + if not found: + return False, "field_missing" + + if operator == "equals": + matched = _equal(actual, expected) + elif operator == "not_equals": + matched = not _equal(actual, expected) + elif operator == "contains": + matched = _contains(actual, expected) + elif operator == "not_contains": + matched = not _contains(actual, expected) + elif operator == "starts_with": + matched = isinstance(actual, str) and isinstance(expected, str) and actual.startswith(expected) + elif operator == "ends_with": + matched = isinstance(actual, str) and isinstance(expected, str) and actual.endswith(expected) + elif operator in {"regex", "not_regex"}: + regex = compile_safe_regex(expected) + matched = regex.search(bounded_regex_input(actual)) is not None + if operator == "not_regex": + matched = not matched + elif operator == "in": + matched = _in(actual, expected) + elif operator == "not_in": + matched = not _in(actual, expected) + elif operator in { + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + }: + try: + left = _decimal(actual) + right = _decimal(expected) + except InvalidOperation: + return False, "numeric_coercion_failed" + matched = { + "greater_than": left > right, + "less_than": left < right, + "greater_or_equal": left >= right, + "less_or_equal": left <= right, + }[operator] + elif operator in {"is_true", "is_false"}: + try: + boolean = _coerce_boolean(actual) + except ValueError: + return False, "boolean_coercion_failed" + matched = boolean if operator == "is_true" else not boolean + else: # pragma: no cover - protected by validation + raise ConditionValidationError(f"unsupported operator {operator}") + + return matched, "condition_matched" if matched else "condition_mismatched" + + +def validate_condition_tree(tree: Any, *, path: str = "condition_tree") -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + node_count = 0 + + def visit(node: Any, node_path: str, depth: int) -> None: + nonlocal node_count + node_count += 1 + if node_count > MAX_CONDITION_NODES: + issues.append( + ValidationIssue(node_path, "condition_node_limit", "condition tree has too many nodes") + ) + return + if depth > MAX_CONDITION_DEPTH: + issues.append( + ValidationIssue(node_path, "condition_depth_limit", "condition tree is nested too deeply") + ) + return + if not isinstance(node, dict): + issues.append( + ValidationIssue(node_path, "invalid_condition_node", "condition node must be an object") + ) + return + if not node: + return # Explicit catch-all rule. + + logical = [key for key in LOGICAL_KEYS if key in node] + if logical: + if len(logical) != 1 or len(node) != 1: + issues.append( + ValidationIssue( + node_path, + "ambiguous_condition_node", + "logical condition nodes must contain exactly one of all, any or none", + ) + ) + return + key = logical[0] + children = node[key] + if not isinstance(children, list): + issues.append( + ValidationIssue( + f"{node_path}.{key}", + "invalid_condition_children", + f"{key} must contain a list", + ) + ) + return + for index, child in enumerate(children): + visit(child, f"{node_path}.{key}[{index}]", depth + 1) + return + + allowed = {"field", "operator", "value"} + unknown = sorted(set(node) - allowed) + if unknown: + issues.append( + ValidationIssue( + node_path, + "unknown_condition_keys", + "unknown condition keys: " + ", ".join(unknown), + ) + ) + if "field" not in node: + issues.append(ValidationIssue(node_path, "missing_field", "condition field is required")) + else: + issues.extend(validate_field_reference(node["field"], path=f"{node_path}.field")) + if "operator" not in node: + issues.append( + ValidationIssue(node_path, "missing_operator", "condition operator is required") + ) + return + try: + operator = normalize_operator(node["operator"]) + except ConditionValidationError as exc: + issues.append(ValidationIssue(f"{node_path}.operator", exc.code, str(exc))) + return + + no_value = {"exists", "not_exists", "is_true", "is_false"} + if operator not in no_value and "value" not in node: + issues.append( + ValidationIssue(node_path, "missing_condition_value", f"operator {operator} requires value") + ) + if operator in {"regex", "not_regex"} and "value" in node: + try: + validate_regex_pattern(node["value"]) + except RegexSafetyError as exc: + issues.append(ValidationIssue(f"{node_path}.value", exc.code, str(exc))) + if operator in {"in", "not_in"} and "value" in node: + value = node["value"] + if not isinstance(value, (list, tuple, dict)): + issues.append( + ValidationIssue( + f"{node_path}.value", + "invalid_collection", + f"operator {operator} requires a list or object value", + ) + ) + if operator in { + "greater_than", + "less_than", + "greater_or_equal", + "less_or_equal", + } and "value" in node: + try: + _decimal(node["value"]) + except InvalidOperation: + issues.append( + ValidationIssue( + f"{node_path}.value", + "invalid_numeric_value", + f"operator {operator} requires a finite numeric value", + ) + ) + + visit(tree, path, 0) + # Avoid emitting the same global limit many times. + deduped: List[ValidationIssue] = [] + seen = set() + for issue in issues: + key = (issue.path, issue.code, issue.message) + if key not in seen: + seen.add(key) + deduped.append(issue) + return deduped + + +def evaluate_condition_tree( + tree: Mapping[str, Any], + context: Mapping[str, Any], + *, + validate: bool = True, + path: str = "$", +) -> ConditionResult: + """Evaluate every node so Explain traces contain matches and mismatches.""" + + if validate: + issues = validate_condition_tree(tree) + if issues: + first = issues[0] + raise ConditionValidationError(first.message, path=first.path) + + def visit(node: Mapping[str, Any], node_path: str) -> ConditionResult: + if not node: + return ConditionResult(True, "catch_all", "empty condition matches all events", path=node_path, node_type="all") + + logical = next((key for key in ("all", "any", "none") if key in node), None) + if logical is not None: + children = tuple( + visit(child, f"{node_path}.{logical}[{index}]") + for index, child in enumerate(node[logical]) + ) + child_matches = [child.matched for child in children] + if logical == "all": + matched = all(child_matches) + elif logical == "any": + matched = any(child_matches) + else: + matched = not any(child_matches) + return ConditionResult( + matched=matched, + code=f"{logical}_{'matched' if matched else 'mismatched'}", + reason=f"{logical} group {'matched' if matched else 'did not match'}", + path=node_path, + node_type=logical, + children=children, + ) + + field_reference = node["field"] + operator = normalize_operator(node["operator"]) + expected = node["value"] if "value" in node else MISSING + resolution = resolve_field(context, field_reference) + try: + matched, code = _evaluate_operator( + operator, + resolution.value, + expected, + resolution.found, + ) + reason = code.replace("_", " ") + except RegexSafetyError as exc: + matched = False + code = exc.code + reason = str(exc) + return ConditionResult( + matched=matched, + code=code, + reason=reason, + path=node_path, + field=resolution.normalized_reference, + operator=operator, + expected=expected, + actual=None if not resolution.found else resolution.value, + found=resolution.found, + ) + + return visit(tree, path) diff --git a/app/services/orchestration/errors.py b/app/services/orchestration/errors.py new file mode 100644 index 0000000..bd72805 --- /dev/null +++ b/app/services/orchestration/errors.py @@ -0,0 +1,61 @@ +"""Safe error types for the Event Orchestration evaluator.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass(frozen=True) +class ValidationIssue: + """A structured validation problem suitable for API and UI responses.""" + + path: str + code: str + message: str + severity: str = "error" + + def to_dict(self) -> Dict[str, str]: + return { + "path": self.path, + "code": self.code, + "message": self.message, + "severity": self.severity, + } + + +class OrchestrationEvaluationError(ValueError): + """Base class for deterministic evaluator failures.""" + + code = "evaluation_error" + + def __init__(self, message: str, *, path: Optional[str] = None): + self.path = path + super().__init__(message) + + def to_dict(self) -> Dict[str, Any]: + return { + "code": self.code, + "message": str(self), + "path": self.path, + } + + +class FieldResolutionError(OrchestrationEvaluationError): + code = "invalid_field_reference" + + +class ConditionValidationError(OrchestrationEvaluationError): + code = "invalid_condition" + + +class RegexSafetyError(OrchestrationEvaluationError): + code = "unsafe_regex" + + +class TemplateValidationError(OrchestrationEvaluationError): + code = "invalid_template" + + +class ExtractionError(OrchestrationEvaluationError): + code = "variable_extraction_failed" diff --git a/app/services/orchestration/evaluator.py b/app/services/orchestration/evaluator.py new file mode 100644 index 0000000..f7d0154 --- /dev/null +++ b/app/services/orchestration/evaluator.py @@ -0,0 +1,68 @@ +"""High-level evaluator facade used by simulation and the future action engine.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Mapping, Sequence, Tuple + +from .conditions import ConditionResult, evaluate_condition_tree +from .fields import build_context +from .templates import TemplateRenderResult, render_template +from .variables import ExtractionResult, extract_variables + + +@dataclass(frozen=True) +class RuleEvaluationResult: + matched: bool + condition: ConditionResult + variables: Mapping[str, Any] + extraction_steps: Tuple[Mapping[str, Any], ...] + extraction_outcome: str + + def to_dict(self) -> Dict[str, Any]: + return { + "matched": self.matched, + "condition": self.condition.to_dict(), + "variables": dict(self.variables), + "extraction_steps": list(self.extraction_steps), + "extraction_outcome": self.extraction_outcome, + } + + +def evaluate_rule( + *, + condition_tree: Mapping[str, Any], + context: Mapping[str, Any], + extractors: Sequence[Mapping[str, Any]] = (), +) -> RuleEvaluationResult: + condition = evaluate_condition_tree(condition_tree, context) + if not condition.matched or not extractors: + return RuleEvaluationResult( + condition.matched, + condition, + dict(context.get("variables") or {}), + (), + "continue", + ) + + extraction = extract_variables(extractors, context) + return RuleEvaluationResult( + condition.matched, + condition, + extraction.variables, + tuple(step.to_dict() for step in extraction.steps), + extraction.outcome, + ) + + +__all__ = [ + "ConditionResult", + "ExtractionResult", + "RuleEvaluationResult", + "TemplateRenderResult", + "build_context", + "evaluate_condition_tree", + "evaluate_rule", + "extract_variables", + "render_template", +] diff --git a/app/services/orchestration/fields.py b/app/services/orchestration/fields.py new file mode 100644 index 0000000..1150501 --- /dev/null +++ b/app/services/orchestration/fields.py @@ -0,0 +1,183 @@ +"""Restricted field resolution for orchestration conditions and templates. + +The resolver only traverses mappings and sequences. It never performs Python +attribute access, method calls or descriptor evaluation. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, Mapping, Sequence, Tuple + +from .errors import FieldResolutionError, ValidationIssue +from .limits import MAX_FIELD_REFERENCE_LENGTH + + +ALLOWED_ROOTS = frozenset( + { + "event", + "labels", + "raw", + "variables", + "route", + "service", + "team", + "integration", + "time", + "result", + } +) + +# Existing IncidentRelay rules commonly use bare normalized-event fields such +# as "severity". Keep that notation deterministic by resolving it under event. +_BARE_EVENT_FIELD = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +_SEGMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_-]*$") +_INDEX = re.compile(r"^(0|[1-9][0-9]*)$") + + +class _Missing: + def __repr__(self) -> str: + return "MISSING" + + +MISSING = _Missing() + + +@dataclass(frozen=True) +class FieldResolution: + reference: str + found: bool + value: Any = MISSING + normalized_reference: str = "" + + def to_dict(self) -> Dict[str, Any]: + return { + "reference": self.reference, + "normalized_reference": self.normalized_reference, + "found": self.found, + "value": None if not self.found else self.value, + } + + +def normalize_field_reference(reference: str) -> str: + if not isinstance(reference, str): + raise FieldResolutionError("field reference must be a string") + reference = reference.strip() + if not reference: + raise FieldResolutionError("field reference cannot be empty") + if len(reference) > MAX_FIELD_REFERENCE_LENGTH: + raise FieldResolutionError("field reference exceeds the size limit") + if reference.startswith(".") or reference.endswith(".") or ".." in reference: + raise FieldResolutionError("field reference contains an empty segment") + + parts = reference.split(".") + if parts[0] not in ALLOWED_ROOTS: + if len(parts) == 1 and _BARE_EVENT_FIELD.fullmatch(parts[0]): + parts.insert(0, "event") + else: + raise FieldResolutionError( + "field reference must start with one of: " + + ", ".join(sorted(ALLOWED_ROOTS)) + ) + + for index, part in enumerate(parts): + if not part: + raise FieldResolutionError("field reference contains an empty segment") + if index == 0: + if part not in ALLOWED_ROOTS: + raise FieldResolutionError("unsupported field root") + continue + if part.startswith("__") and part.endswith("__"): + raise FieldResolutionError("dunder field path segments are not allowed") + if not (_SEGMENT.fullmatch(part) or _INDEX.fullmatch(part)): + raise FieldResolutionError( + f"invalid field path segment {part!r}; only names and list indexes are allowed" + ) + return ".".join(parts) + + +def validate_field_reference(reference: Any, *, path: str = "field") -> Iterable[ValidationIssue]: + try: + normalize_field_reference(reference) + except FieldResolutionError as exc: + yield ValidationIssue(path, exc.code, str(exc)) + + +def _mapping_get(value: Mapping[str, Any], segment: str) -> Tuple[bool, Any]: + if segment in value: + return True, value[segment] + return False, MISSING + + +def _sequence_get(value: Sequence[Any], segment: str) -> Tuple[bool, Any]: + if not _INDEX.fullmatch(segment): + return False, MISSING + index = int(segment) + if index >= len(value): + return False, MISSING + return True, value[index] + + +def resolve_field(context: Mapping[str, Any], reference: str) -> FieldResolution: + """Resolve a safe dotted reference against an orchestration context.""" + + normalized = normalize_field_reference(reference) + parts = normalized.split(".") + current: Any = context + + for segment in parts: + if isinstance(current, Mapping): + found, current = _mapping_get(current, segment) + elif isinstance(current, Sequence) and not isinstance( + current, (str, bytes, bytearray) + ): + found, current = _sequence_get(current, segment) + else: + found, current = False, MISSING + if not found: + return FieldResolution( + reference=reference, + normalized_reference=normalized, + found=False, + ) + + return FieldResolution( + reference=reference, + normalized_reference=normalized, + found=True, + value=current, + ) + + +def build_context( + *, + event: Mapping[str, Any] | None = None, + labels: Mapping[str, Any] | None = None, + raw: Mapping[str, Any] | None = None, + variables: Mapping[str, Any] | None = None, + route: Mapping[str, Any] | None = None, + service: Mapping[str, Any] | None = None, + team: Mapping[str, Any] | None = None, + integration: Mapping[str, Any] | None = None, + time: Mapping[str, Any] | None = None, + result: Mapping[str, Any] | None = None, +) -> Dict[str, Any]: + """Build a complete evaluator context without mutating caller mappings.""" + + event_copy = dict(event or {}) + label_source = labels if labels is not None else event_copy.get("labels") + labels_copy = dict(label_source or {}) + event_copy["labels"] = labels_copy + return { + "event": event_copy, + "labels": labels_copy, + "raw": dict(raw or {}), + "variables": dict(variables or {}), + "route": dict(route or {}), + "service": dict(service or {}), + "team": dict(team or {}), + "integration": dict(integration or {}), + "time": dict(time or {}), + "result": dict(result or {}), + } diff --git a/app/services/orchestration/limits.py b/app/services/orchestration/limits.py new file mode 100644 index 0000000..ea3095c --- /dev/null +++ b/app/services/orchestration/limits.py @@ -0,0 +1,18 @@ +"""Central limits for deterministic and bounded orchestration evaluation.""" + +MAX_CONDITION_DEPTH = 20 +MAX_CONDITION_NODES = 256 +MAX_FIELD_REFERENCE_LENGTH = 256 +MAX_REGEX_PATTERN_LENGTH = 512 +MAX_REGEX_INPUT_LENGTH = 16_384 +MAX_TEMPLATE_LENGTH = 8_192 +MAX_TEMPLATE_EXPRESSIONS = 64 +MAX_TEMPLATE_EXPRESSION_LENGTH = 512 +MAX_TEMPLATE_OUTPUT_LENGTH = 16_384 +MAX_TEMPLATE_FILTERS = 8 +MAX_VARIABLES = 128 +MAX_VARIABLE_NAME_LENGTH = 64 +MAX_VARIABLE_VALUE_LENGTH = 16_384 +MAX_JSON_PATH_LENGTH = 512 +MAX_SPLIT_PARTS = 256 +MAX_TRACE_VALUE_LENGTH = 512 diff --git a/app/services/orchestration/regex.py b/app/services/orchestration/regex.py new file mode 100644 index 0000000..577e592 --- /dev/null +++ b/app/services/orchestration/regex.py @@ -0,0 +1,91 @@ +"""Bounded regular-expression helpers. + +Python's standard ``re`` engine has no portable timeout on the supported +IncidentRelay Python versions, so orchestration regexes use strict size and +complexity checks before compilation and input-size limits before matching. +""" + +from __future__ import annotations + +import re +from typing import Pattern + +from .errors import RegexSafetyError +from .limits import MAX_REGEX_INPUT_LENGTH, MAX_REGEX_PATTERN_LENGTH + + +# Reject constructs that are either unnecessary for orchestration matching or +# commonly associated with catastrophic backtracking / hidden complexity. +_FORBIDDEN_PATTERNS = ( + (re.compile(r"\\[1-9]"), "numeric backreferences are not allowed"), + (re.compile(r"\(\?P="), "named backreferences are not allowed"), + (re.compile(r"\(\?<[=!]"), "lookbehind is not allowed"), + (re.compile(r"\(\?\("), "conditional groups are not allowed"), + (re.compile(r"\(\?>"), "atomic groups are not supported"), +) + +# Approximate nested-quantifier detection. This intentionally rejects some +# valid but complex patterns in exchange for predictable ingestion latency. +_NESTED_QUANTIFIER = re.compile( + r"\((?:[^()\\]|\\.)*(?:\*|\+|\?|\{\d+(?:,\d*)?\})(?:[^()\\]|\\.)*\)" + r"\s*(?:\*|\+|\{\d+(?:,\d*)?\})" +) +_QUANTIFIED_ALTERNATION = re.compile( + r"\((?:[^()\\]|\\.)*\|(?:[^()\\]|\\.)*\)" + r"\s*(?:\*|\+|\{\d+(?:,\d*)?\})" +) +_AMBIGUOUS_DOT_REPEAT = re.compile(r"(?:\.\*|\.\+)\s*(?:\.\*|\.\+)") + + +def normalize_named_groups(pattern: str) -> str: + """Accept architecture-style ``(?...)`` named groups safely.""" + + return re.sub(r"\(\?<([A-Za-z_][A-Za-z0-9_]*)>", r"(?P<\1>", pattern) + + +def validate_regex_pattern(pattern: str) -> str: + if not isinstance(pattern, str): + raise RegexSafetyError("regex pattern must be a string") + if len(pattern) > MAX_REGEX_PATTERN_LENGTH: + raise RegexSafetyError("regex pattern exceeds the size limit") + if "\x00" in pattern: + raise RegexSafetyError("regex pattern cannot contain NUL bytes") + + normalized = normalize_named_groups(pattern) + for detector, message in _FORBIDDEN_PATTERNS: + if detector.search(normalized): + raise RegexSafetyError(message) + if _NESTED_QUANTIFIER.search(normalized): + raise RegexSafetyError("nested quantified groups are not allowed") + if _QUANTIFIED_ALTERNATION.search(normalized): + raise RegexSafetyError("quantified alternation groups are not allowed") + if _AMBIGUOUS_DOT_REPEAT.search(normalized): + raise RegexSafetyError("ambiguous repeated wildcard expressions are not allowed") + if sum(normalized.count(token) for token in ("*", "+", "?", "{")) > 64: + raise RegexSafetyError("regex pattern has too many quantifiers") + if normalized.count("|") > 32: + raise RegexSafetyError("regex pattern has too many alternatives") + + try: + re.compile(normalized) + except re.error as exc: + raise RegexSafetyError(f"invalid regex pattern: {exc.msg}") from exc + return normalized + + +def compile_safe_regex(pattern: str, *, flags: int = 0) -> Pattern[str]: + return re.compile(validate_regex_pattern(pattern), flags) + + +def bounded_regex_input(value: object) -> str: + if value is None: + text = "" + elif isinstance(value, bool): + text = "true" if value else "false" + elif isinstance(value, (str, int, float)): + text = str(value) + else: + raise RegexSafetyError("regex input must be a scalar value") + if len(text) > MAX_REGEX_INPUT_LENGTH: + raise RegexSafetyError("regex input exceeds the size limit") + return text diff --git a/app/services/orchestration/templates.py b/app/services/orchestration/templates.py new file mode 100644 index 0000000..41c6d29 --- /dev/null +++ b/app/services/orchestration/templates.py @@ -0,0 +1,259 @@ +"""A restricted interpolation engine for Event Orchestration. + +This is deliberately not Jinja. Expressions are limited to safe field +references followed by whitelisted string filters with literal arguments. +""" + +from __future__ import annotations + +import ast +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, Sequence, Tuple + +from .errors import TemplateValidationError, ValidationIssue +from .fields import MISSING, normalize_field_reference, resolve_field +from .limits import ( + MAX_TEMPLATE_EXPRESSION_LENGTH, + MAX_TEMPLATE_EXPRESSIONS, + MAX_TEMPLATE_FILTERS, + MAX_TEMPLATE_LENGTH, + MAX_TEMPLATE_OUTPUT_LENGTH, +) + + +_EXPRESSION = re.compile(r"{{(.*?)}}", re.DOTALL) +_FILTER = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$", re.DOTALL) +_ALLOWED_FILTERS = frozenset({"lower", "upper", "trim", "default", "replace", "truncate"}) + + +@dataclass(frozen=True) +class ParsedExpression: + field: str + filters: Tuple[Tuple[str, Tuple[Any, ...]], ...] + + +@dataclass(frozen=True) +class TemplateRenderResult: + value: str + references: Tuple[str, ...] + + def to_dict(self) -> Dict[str, Any]: + return {"value": self.value, "references": list(self.references)} + + +def _split_pipeline(expression: str) -> List[str]: + parts: List[str] = [] + start = 0 + quote: str | None = None + escaped = False + depth = 0 + for index, char in enumerate(expression): + if escaped: + escaped = False + continue + if char == "\\" and quote is not None: + escaped = True + continue + if quote is not None: + if char == quote: + quote = None + continue + if char in {"'", '"'}: + quote = char + elif char == "(": + depth += 1 + elif char == ")": + depth -= 1 + if depth < 0: + raise TemplateValidationError("unbalanced filter parentheses") + elif char == "|" and depth == 0: + parts.append(expression[start:index].strip()) + start = index + 1 + if quote is not None or depth != 0: + raise TemplateValidationError("unbalanced template expression") + parts.append(expression[start:].strip()) + return parts + + +def _literal_arguments(raw: str | None) -> Tuple[Any, ...]: + if raw is None or not raw.strip(): + return () + try: + value = ast.literal_eval(f"({raw},)") + except (SyntaxError, ValueError) as exc: + raise TemplateValidationError("filter arguments must be string, number, boolean or null literals") from exc + if not isinstance(value, tuple): # pragma: no cover + raise TemplateValidationError("invalid filter arguments") + for item in value: + if not isinstance(item, (str, int, float, bool, type(None))): + raise TemplateValidationError("filter arguments cannot contain collections or objects") + return value + + +def _validate_filter(name: str, args: Tuple[Any, ...]) -> None: + if name not in _ALLOWED_FILTERS: + raise TemplateValidationError(f"unsupported template filter {name!r}") + required = { + "lower": (0, 0), + "upper": (0, 0), + "trim": (0, 0), + "default": (1, 1), + "replace": (2, 2), + "truncate": (1, 1), + }[name] + if not required[0] <= len(args) <= required[1]: + raise TemplateValidationError( + f"filter {name} expects {required[0]} argument(s)" + ) + if name == "replace" and not all(isinstance(arg, str) for arg in args): + raise TemplateValidationError("replace arguments must be strings") + if name == "truncate": + length = args[0] + if isinstance(length, bool) or not isinstance(length, int) or length < 0: + raise TemplateValidationError("truncate length must be a non-negative integer") + if length > MAX_TEMPLATE_OUTPUT_LENGTH: + raise TemplateValidationError("truncate length exceeds the output limit") + + +def parse_expression(expression: str) -> ParsedExpression: + expression = expression.strip() + if not expression: + raise TemplateValidationError("template expression cannot be empty") + if len(expression) > MAX_TEMPLATE_EXPRESSION_LENGTH: + raise TemplateValidationError("template expression exceeds the size limit") + parts = _split_pipeline(expression) + field = normalize_field_reference(parts[0]) + if len(parts) - 1 > MAX_TEMPLATE_FILTERS: + raise TemplateValidationError("template expression has too many filters") + + filters: List[Tuple[str, Tuple[Any, ...]]] = [] + for raw_filter in parts[1:]: + match = _FILTER.fullmatch(raw_filter) + if not match: + raise TemplateValidationError(f"invalid template filter syntax {raw_filter!r}") + name = match.group(1) + args = _literal_arguments(match.group(2)) + _validate_filter(name, args) + filters.append((name, args)) + return ParsedExpression(field=field, filters=tuple(filters)) + + +def validate_template(template: Any, *, path: str = "template") -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + if not isinstance(template, str): + return [ValidationIssue(path, "invalid_template_type", "template must be a string")] + if len(template) > MAX_TEMPLATE_LENGTH: + return [ValidationIssue(path, "template_size_limit", "template exceeds the size limit")] + + matches = list(_EXPRESSION.finditer(template)) + if len(matches) > MAX_TEMPLATE_EXPRESSIONS: + issues.append( + ValidationIssue(path, "template_expression_limit", "template has too many expressions") + ) + # Any leftover delimiter indicates malformed syntax. + stripped = _EXPRESSION.sub("", template) + if "{{" in stripped or "}}" in stripped: + issues.append( + ValidationIssue(path, "unbalanced_template_delimiter", "template contains unbalanced delimiters") + ) + + for index, match in enumerate(matches): + try: + parse_expression(match.group(1)) + except (TemplateValidationError, ValueError) as exc: + code = getattr(exc, "code", "invalid_template") + issues.append( + ValidationIssue(f"{path}.expression[{index}]", code, str(exc)) + ) + return issues + + +def _stringify(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (str, int, float)): + return str(value) + raise TemplateValidationError("templates can only render scalar values") + + +def _apply_filter(value: Any, name: str, args: Sequence[Any], *, missing: bool) -> Tuple[Any, bool]: + if name == "default": + if missing or value is None or value == "": + return args[0], False + return value, missing + if missing: + return value, missing + text = _stringify(value) + if name == "lower": + return text.lower(), False + if name == "upper": + return text.upper(), False + if name == "trim": + return text.strip(), False + if name == "replace": + return text.replace(args[0], args[1]), False + if name == "truncate": + return text[: args[0]], False + raise TemplateValidationError(f"unsupported template filter {name!r}") + + +def render_template( + template: str, + context: Mapping[str, Any], + *, + max_output_length: int = MAX_TEMPLATE_OUTPUT_LENGTH, +) -> TemplateRenderResult: + issues = validate_template(template) + if issues: + first = issues[0] + raise TemplateValidationError(first.message, path=first.path) + if max_output_length < 0 or max_output_length > MAX_TEMPLATE_OUTPUT_LENGTH: + raise TemplateValidationError("invalid template output limit") + + references: List[str] = [] + output: List[str] = [] + cursor = 0 + length = 0 + + def append(value: str) -> None: + nonlocal length + length += len(value) + if length > max_output_length: + raise TemplateValidationError("rendered template exceeds the output limit") + output.append(value) + + for match in _EXPRESSION.finditer(template): + append(template[cursor : match.start()]) + parsed = parse_expression(match.group(1)) + references.append(parsed.field) + resolution = resolve_field(context, parsed.field) + value = resolution.value + missing = not resolution.found + for name, args in parsed.filters: + value, missing = _apply_filter(value, name, args, missing=missing) + if missing: + raise TemplateValidationError( + f"template field {parsed.field!r} does not exist", + path=parsed.field, + ) + append(_stringify(value)) + cursor = match.end() + append(template[cursor:]) + return TemplateRenderResult("".join(output), tuple(references)) + + +def find_templates(value: Any, *, path: str = "value") -> Iterable[Tuple[str, str]]: + """Yield ``(path, template)`` for strings containing template syntax.""" + + if isinstance(value, str): + if "{{" in value or "}}" in value: + yield path, value + elif isinstance(value, dict): + for key, child in value.items(): + yield from find_templates(child, path=f"{path}.{key}") + elif isinstance(value, list): + for index, child in enumerate(value): + yield from find_templates(child, path=f"{path}[{index}]") diff --git a/app/services/orchestration/validation.py b/app/services/orchestration/validation.py new file mode 100644 index 0000000..870f4c4 --- /dev/null +++ b/app/services/orchestration/validation.py @@ -0,0 +1,55 @@ +"""Publication-time validation for orchestration conditions and templates.""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from .conditions import validate_condition_tree +from .errors import ValidationIssue +from .templates import find_templates, validate_template +from .variables import EXTRACTION_TYPES, validate_extractor + + +def validate_rule_definition( + condition_tree: Any, + actions: Any, + *, + path: str = "rule", +) -> Dict[str, List[ValidationIssue]]: + errors: List[ValidationIssue] = [] + warnings: List[ValidationIssue] = [] + + errors.extend(validate_condition_tree(condition_tree, path=f"{path}.condition_tree")) + if not isinstance(actions, list): + errors.append( + ValidationIssue(f"{path}.actions", "invalid_actions", "actions must be a list") + ) + return {"errors": errors, "warnings": warnings} + + for index, action in enumerate(actions): + action_path = f"{path}.actions[{index}]" + if not isinstance(action, dict): + errors.append( + ValidationIssue(action_path, "invalid_action", "action must be an object") + ) + continue + if action.get("type") in EXTRACTION_TYPES: + errors.extend(validate_extractor(action, path=action_path)) + for template_path, template in find_templates(action, path=action_path): + errors.extend(validate_template(template, path=template_path)) + + def dedupe(items: List[ValidationIssue]) -> List[ValidationIssue]: + result: List[ValidationIssue] = [] + seen = set() + for item in items: + key = (item.path, item.code, item.message, item.severity) + if key not in seen: + seen.add(key) + result.append(item) + return result + + return {"errors": dedupe(errors), "warnings": dedupe(warnings)} + + +def issues_to_messages(issues: List[ValidationIssue]) -> List[str]: + return [f"{issue.path}: {issue.message}" for issue in issues] diff --git a/app/services/orchestration/variables.py b/app/services/orchestration/variables.py new file mode 100644 index 0000000..0e9e6b3 --- /dev/null +++ b/app/services/orchestration/variables.py @@ -0,0 +1,435 @@ +"""Restricted variable extraction for Event Orchestration.""" + +from __future__ import annotations + +import copy +import json +import re +from dataclasses import dataclass +from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple + +from .errors import ExtractionError, RegexSafetyError, ValidationIssue +from .fields import MISSING, normalize_field_reference, resolve_field +from .limits import ( + MAX_JSON_PATH_LENGTH, + MAX_SPLIT_PARTS, + MAX_VARIABLE_NAME_LENGTH, + MAX_VARIABLE_VALUE_LENGTH, + MAX_VARIABLES, +) +from .regex import bounded_regex_input, compile_safe_regex, validate_regex_pattern +from .templates import render_template, validate_template + + +VARIABLE_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +EXTRACTION_TYPES = frozenset( + { + "extract_regex", + "copy_field", + "copy_to_variable", + "json_path", + "split", + "set_variable", + "static", + "lowercase", + "uppercase", + "trim", + } +) +FAILURE_MODES = frozenset({"continue", "stop_rule", "stop_orchestration"}) + + +@dataclass(frozen=True) +class ExtractionStepResult: + index: int + extractor_type: str + success: bool + code: str + reason: str + variables: Mapping[str, Any] + failure_mode: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "index": self.index, + "type": self.extractor_type, + "success": self.success, + "code": self.code, + "reason": self.reason, + "variables": dict(self.variables), + "failure_mode": self.failure_mode, + } + + +@dataclass(frozen=True) +class ExtractionResult: + variables: Mapping[str, Any] + steps: Tuple[ExtractionStepResult, ...] + outcome: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "variables": dict(self.variables), + "steps": [step.to_dict() for step in self.steps], + "outcome": self.outcome, + } + + +def _validate_variable_name(name: Any) -> Optional[str]: + if not isinstance(name, str) or not name: + return "variable name is required" + if len(name) > MAX_VARIABLE_NAME_LENGTH: + return "variable name exceeds the size limit" + if not VARIABLE_NAME.fullmatch(name): + return "variable name must start with a letter or underscore and contain only letters, numbers and underscores" + return None + + +def _extractor_target(extractor: Mapping[str, Any]) -> Any: + return extractor.get("name", extractor.get("target")) + + +def _validate_json_path(path: Any) -> Optional[str]: + if not isinstance(path, str) or not path: + return "JSON path is required" + if len(path) > MAX_JSON_PATH_LENGTH: + return "JSON path exceeds the size limit" + try: + _parse_json_path(path) + except ExtractionError as exc: + return str(exc) + return None + + +def validate_extractor(extractor: Any, *, path: str) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + if not isinstance(extractor, dict): + return [ValidationIssue(path, "invalid_extractor", "extractor must be an object")] + extractor_type = extractor.get("type") + if extractor_type not in EXTRACTION_TYPES: + return [ + ValidationIssue( + f"{path}.type", + "unsupported_extractor", + f"unsupported variable extractor {extractor_type!r}", + ) + ] + failure_mode = extractor.get("on_failure", "continue") + if failure_mode not in FAILURE_MODES: + issues.append( + ValidationIssue( + f"{path}.on_failure", + "invalid_failure_mode", + "on_failure must be continue, stop_rule or stop_orchestration", + ) + ) + + if extractor_type == "extract_regex": + source = extractor.get("source") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + compiled = None + try: + normalized_pattern = validate_regex_pattern(extractor.get("pattern")) + compiled = re.compile(normalized_pattern) + except RegexSafetyError as exc: + issues.append(ValidationIssue(f"{path}.pattern", exc.code, str(exc))) + target = _extractor_target(extractor) + if target is not None: + message = _validate_variable_name(target) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + group = extractor.get("group") + if group is not None and compiled is not None: + if isinstance(group, bool) or not isinstance(group, (int, str)): + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "regex group must be an integer index or named group")) + elif isinstance(group, int) and (group < 0 or group > compiled.groups): + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "regex group index does not exist")) + elif isinstance(group, str) and group not in compiled.groupindex: + issues.append(ValidationIssue(f"{path}.group", "invalid_regex_group", "named regex group does not exist")) + elif compiled is not None and not compiled.groupindex: + issues.append(ValidationIssue(path, "missing_regex_targets", "regex extraction without a target requires named groups")) + elif extractor_type in {"copy_field", "copy_to_variable"}: + try: + normalize_field_reference(extractor.get("source")) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + elif extractor_type == "json_path": + source = extractor.get("source", "raw") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_json_path(extractor.get("path")) + if message: + issues.append(ValidationIssue(f"{path}.path", "invalid_json_path", message)) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + elif extractor_type == "split": + try: + normalize_field_reference(extractor.get("source")) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + delimiter = extractor.get("delimiter") + if not isinstance(delimiter, str) or not delimiter: + issues.append(ValidationIssue(f"{path}.delimiter", "invalid_delimiter", "split delimiter must be a non-empty string")) + targets = extractor.get("targets") + target = _extractor_target(extractor) + if targets is not None: + if not isinstance(targets, list) or not targets: + issues.append(ValidationIssue(f"{path}.targets", "invalid_targets", "split targets must be a non-empty list")) + else: + if len(targets) > MAX_VARIABLES: + issues.append(ValidationIssue(f"{path}.targets", "variable_limit", "split has too many targets")) + seen_targets = set() + for index, name in enumerate(targets): + message = _validate_variable_name(name) + if message: + issues.append(ValidationIssue(f"{path}.targets[{index}]", "invalid_variable_name", message)) + elif name in seen_targets: + issues.append(ValidationIssue(f"{path}.targets[{index}]", "duplicate_variable_name", "split targets must be unique")) + else: + seen_targets.add(name) + elif target is not None: + message = _validate_variable_name(target) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + index = extractor.get("index") + if isinstance(index, bool) or not isinstance(index, int) or index < 0: + issues.append(ValidationIssue(f"{path}.index", "invalid_split_index", "split index must be a non-negative integer")) + else: + issues.append(ValidationIssue(path, "missing_split_target", "split requires name and index, or targets")) + elif extractor_type in {"set_variable", "static"}: + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + value = extractor.get("value") + if isinstance(value, str) and ("{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.value")) + elif extractor_type in {"lowercase", "uppercase", "trim"}: + source = extractor.get("source") + try: + normalize_field_reference(source) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.source", "invalid_field_reference", str(exc))) + message = _validate_variable_name(_extractor_target(extractor)) + if message: + issues.append(ValidationIssue(f"{path}.name", "invalid_variable_name", message)) + return issues + + +def validate_extractors(extractors: Any, *, path: str = "extractors") -> List[ValidationIssue]: + if not isinstance(extractors, list): + return [ValidationIssue(path, "invalid_extractors", "extractors must be a list")] + if len(extractors) > MAX_VARIABLES: + return [ValidationIssue(path, "extractor_limit", "too many variable extractors")] + issues: List[ValidationIssue] = [] + for index, extractor in enumerate(extractors): + issues.extend(validate_extractor(extractor, path=f"{path}[{index}]")) + return issues + + +def _parse_json_path(path: str) -> List[Any]: + """Parse a small, non-executable JSONPath subset. + + Supported forms: ``$``, ``$.a.b``, ``$['a']``, ``$[0]`` and combinations. + Wildcards, filters, scripts and recursive descent are intentionally absent. + """ + + if not path.startswith("$"): + raise ExtractionError("JSON path must start with $") + tokens: List[Any] = [] + index = 1 + while index < len(path): + if path[index] == ".": + index += 1 + match = re.match(r"[A-Za-z_][A-Za-z0-9_-]*", path[index:]) + if not match: + raise ExtractionError("invalid dotted JSON path segment") + tokens.append(match.group(0)) + index += len(match.group(0)) + elif path[index] == "[": + end = path.find("]", index + 1) + if end == -1: + raise ExtractionError("unclosed JSON path bracket") + raw = path[index + 1 : end].strip() + if re.fullmatch(r"0|[1-9][0-9]*", raw): + tokens.append(int(raw)) + elif len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in {"'", '"'}: + try: + value = bytes(raw[1:-1], "utf-8").decode("unicode_escape") + except UnicodeDecodeError as exc: + raise ExtractionError("invalid escaped JSON path key") from exc + if not value: + raise ExtractionError("JSON path key cannot be empty") + tokens.append(value) + else: + raise ExtractionError("JSON path brackets only support integer indexes or quoted keys") + index = end + 1 + else: + raise ExtractionError("invalid JSON path syntax") + return tokens + + +def _json_path_get(value: Any, path: str) -> Any: + current = value + for token in _parse_json_path(path): + if isinstance(token, int): + if not isinstance(current, Sequence) or isinstance(current, (str, bytes, bytearray)): + raise ExtractionError("JSON path expected a list") + if token >= len(current): + raise ExtractionError("JSON path index does not exist") + current = current[token] + else: + if not isinstance(current, Mapping) or token not in current: + raise ExtractionError("JSON path key does not exist") + current = current[token] + return current + + +def _bounded_value(value: Any) -> Any: + try: + encoded = json.dumps( + value, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + ) + except (TypeError, ValueError) as exc: + raise ExtractionError("extracted variable must be JSON-compatible") from exc + if len(encoded) > MAX_VARIABLE_VALUE_LENGTH: + raise ExtractionError("extracted variable exceeds the value size limit") + return copy.deepcopy(value) + + +def _set_variable(variables: MutableMapping[str, Any], name: str, value: Any) -> None: + if name not in variables and len(variables) >= MAX_VARIABLES: + raise ExtractionError("variable count exceeds the limit") + variables[name] = _bounded_value(value) + + +def _context_with_variables(context: Mapping[str, Any], variables: Mapping[str, Any]) -> Dict[str, Any]: + result = dict(context) + result["variables"] = dict(variables) + return result + + +def extract_variables( + extractors: Sequence[Mapping[str, Any]], + context: Mapping[str, Any], + *, + initial_variables: Optional[Mapping[str, Any]] = None, +) -> ExtractionResult: + issues = validate_extractors(list(extractors)) + if issues: + first = issues[0] + raise ExtractionError(first.message, path=first.path) + + variables: Dict[str, Any] = {} + initial = dict(initial_variables or context.get("variables") or {}) + if len(initial) > MAX_VARIABLES: + raise ExtractionError("initial variable count exceeds the limit") + for name, value in initial.items(): + message = _validate_variable_name(name) + if message: + raise ExtractionError(message, path=f"variables.{name}") + _set_variable(variables, name, value) + steps: List[ExtractionStepResult] = [] + outcome = "continue" + + for index, extractor in enumerate(extractors): + extractor_type = extractor["type"] + failure_mode = extractor.get("on_failure", "continue") + produced: Dict[str, Any] = {} + try: + current_context = _context_with_variables(context, variables) + if extractor_type == "extract_regex": + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("regex source field does not exist") + match = compile_safe_regex(extractor["pattern"]).search( + bounded_regex_input(resolution.value) + ) + if match is None: + raise ExtractionError("regex did not match") + target = _extractor_target(extractor) + if target is not None: + group = extractor.get("group", 1 if match.lastindex else 0) + try: + produced[target] = match.group(group) + except (IndexError, KeyError) as exc: + raise ExtractionError("requested regex group does not exist") from exc + else: + produced.update(match.groupdict()) + if not produced: + raise ExtractionError("regex extraction requires a target or named groups") + elif extractor_type in {"copy_field", "copy_to_variable"}: + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("copy source field does not exist") + produced[_extractor_target(extractor)] = resolution.value + elif extractor_type == "json_path": + resolution = resolve_field(current_context, extractor.get("source", "raw")) + if not resolution.found: + raise ExtractionError("JSON path source field does not exist") + produced[_extractor_target(extractor)] = _json_path_get( + resolution.value, extractor["path"] + ) + elif extractor_type == "split": + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("split source field does not exist") + if not isinstance(resolution.value, str): + raise ExtractionError("split source must be a string") + parts = resolution.value.split(extractor["delimiter"], MAX_SPLIT_PARTS) + if len(parts) > MAX_SPLIT_PARTS: + raise ExtractionError("split produced too many parts") + targets = extractor.get("targets") + if targets is not None: + if len(parts) < len(targets): + raise ExtractionError("split produced fewer parts than targets") + produced.update(zip(targets, parts)) + else: + split_index = extractor["index"] + if split_index >= len(parts): + raise ExtractionError("split index does not exist") + produced[_extractor_target(extractor)] = parts[split_index] + elif extractor_type in {"set_variable", "static"}: + value = extractor.get("value") + if isinstance(value, str) and ("{{" in value or "}}" in value): + value = render_template(value, current_context).value + produced[_extractor_target(extractor)] = value + elif extractor_type in {"lowercase", "uppercase", "trim"}: + resolution = resolve_field(current_context, extractor["source"]) + if not resolution.found: + raise ExtractionError("transform source field does not exist") + if not isinstance(resolution.value, str): + raise ExtractionError("transform source must be a string") + if extractor_type == "lowercase": + value = resolution.value.lower() + elif extractor_type == "uppercase": + value = resolution.value.upper() + else: + value = resolution.value.strip() + produced[_extractor_target(extractor)] = value + + for name, value in produced.items(): + _set_variable(variables, name, value) + steps.append( + ExtractionStepResult(index, extractor_type, True, "extraction_succeeded", "variables extracted", dict(produced), failure_mode) + ) + except (ExtractionError, RegexSafetyError, ValueError) as exc: + steps.append( + ExtractionStepResult(index, extractor_type, False, getattr(exc, "code", "variable_extraction_failed"), str(exc), {}, failure_mode) + ) + if failure_mode in {"stop_rule", "stop_orchestration"}: + outcome = failure_mode + break + + return ExtractionResult(dict(variables), tuple(steps), outcome) diff --git a/app/version.py b/app/version.py index a77e75f..a0dd45c 100644 --- a/app/version.py +++ b/app/version.py @@ -1,4 +1,4 @@ -SERVICE_VERSION = "1.2" +SERVICE_VERSION = "2.0" def get_service_version(): diff --git a/tests/orchestration/test_orchestration_conditions.py b/tests/orchestration/test_orchestration_conditions.py new file mode 100644 index 0000000..c8d262e --- /dev/null +++ b/tests/orchestration/test_orchestration_conditions.py @@ -0,0 +1,144 @@ +import pytest + +from app.services.orchestration.conditions import ( + evaluate_condition_tree, + validate_condition_tree, +) +from app.services.orchestration.errors import ConditionValidationError +from app.services.orchestration.fields import build_context, resolve_field + + +@pytest.fixture +def context(): + return build_context( + event={ + "title": "[PROD] Database latency", + "severity": "critical", + "count": "12.50", + "enabled": "yes", + "labels": { + "environment": "production", + "component": "database", + "owners": ["sre", "dba"], + }, + }, + raw={"payload": {"source": "grafana"}}, + variables={"region": "eu-central-1"}, + integration={"name": "grafana"}, + ) + + +def test_nested_all_any_none_returns_complete_explain_trace(context): + tree = { + "all": [ + {"field": "labels.environment", "operator": "equals", "value": "production"}, + { + "any": [ + {"field": "severity", "operator": "equals", "value": "critical"}, + {"field": "severity", "operator": "equals", "value": "high"}, + ] + }, + { + "none": [ + {"field": "labels.component", "operator": "equals", "value": "frontend"}, + {"field": "variables.region", "operator": "equals", "value": "us-east-1"}, + ] + }, + ] + } + + result = evaluate_condition_tree(tree, context) + + assert result.matched is True + trace = result.to_dict() + assert trace["node_type"] == "all" + assert len(trace["children"]) == 3 + # The any group evaluates both children for Explain, even after a match. + assert len(trace["children"][1]["children"]) == 2 + assert trace["children"][1]["children"][1]["matched"] is False + + +@pytest.mark.parametrize( + ("condition", "matched"), + [ + ({"field": "event.title", "operator": "contains", "value": "Database"}, True), + ({"field": "event.title", "operator": "not_contains", "value": "RabbitMQ"}, True), + ({"field": "event.title", "operator": "starts_with", "value": "[PROD]"}, True), + ({"field": "event.title", "operator": "ends_with", "value": "latency"}, True), + ({"field": "event.title", "operator": "regex", "value": r"^\[PROD\]"}, True), + ({"field": "event.title", "operator": "not_regex", "value": r"resolved$"}, True), + ({"field": "severity", "operator": "in", "value": ["high", "critical"]}, True), + ({"field": "severity", "operator": "not_in", "value": ["low", "warning"]}, True), + ({"field": "labels.owners", "operator": "contains", "value": "dba"}, True), + ({"field": "labels.environment", "operator": "exists"}, True), + ({"field": "labels.missing", "operator": "not_exists"}, True), + ({"field": "event.count", "operator": "greater_than", "value": 12}, True), + ({"field": "event.count", "operator": "less_or_equal", "value": "12.50"}, True), + ({"field": "event.enabled", "operator": "is_true"}, True), + ({"field": "event.enabled", "operator": "is_false"}, False), + ({"field": "severity", "operator": "eq", "value": "critical"}, True), + ], +) +def test_supported_operators_are_deterministic(context, condition, matched): + assert evaluate_condition_tree(condition, context).matched is matched + + +def test_missing_field_mismatch_has_structured_reason(context): + result = evaluate_condition_tree( + {"field": "labels.unknown", "operator": "equals", "value": "x"}, + context, + ) + + assert result.matched is False + assert result.code == "field_missing" + assert result.found is False + assert result.to_dict()["field"] == "labels.unknown" + + +def test_empty_condition_is_explicit_catch_all(context): + result = evaluate_condition_tree({}, context) + assert result.matched is True + assert result.code == "catch_all" + + +def test_type_coercion_does_not_treat_boolean_as_number(context): + assert evaluate_condition_tree( + {"field": "event.enabled", "operator": "equals", "value": 1}, context + ).matched is False + assert evaluate_condition_tree( + {"field": "event.count", "operator": "equals", "value": 12.5}, context + ).matched is True + + +def test_invalid_tree_fails_before_evaluation(context): + tree = {"all": [], "field": "severity", "operator": "equals", "value": "critical"} + issues = validate_condition_tree(tree) + assert any(issue.code == "ambiguous_condition_node" for issue in issues) + + with pytest.raises(ConditionValidationError, match="exactly one"): + evaluate_condition_tree(tree, context) + + +def test_invalid_field_root_is_rejected(): + issues = validate_condition_tree( + {"field": "__class__.__mro__", "operator": "exists"} + ) + assert any(issue.code == "invalid_field_reference" for issue in issues) + + +def test_resolver_never_reads_python_attributes(context): + from app.services.orchestration.errors import FieldResolutionError + + with pytest.raises(FieldResolutionError, match="dunder"): + resolve_field(context, "event.__class__") + + +def test_equals_null_keeps_expected_value_in_trace(): + context = build_context(event={"owner": None}) + result = evaluate_condition_tree( + {"field": "event.owner", "operator": "equals", "value": None}, + context, + ) + assert result.matched is True + assert "expected" in result.to_dict() + assert result.to_dict()["expected"] is None diff --git a/tests/orchestration/test_orchestration_publication_validation.py b/tests/orchestration/test_orchestration_publication_validation.py new file mode 100644 index 0000000..e0b6506 --- /dev/null +++ b/tests/orchestration/test_orchestration_publication_validation.py @@ -0,0 +1,87 @@ +"""Integration checks for the workstream #17 publication validator hook.""" + +import pytest + +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, +) +from app.modules.db.orchestrations_repo import ( + OrchestrationValidationError, + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, + validate_version, +) +from tests.factories import create_group, create_user + + +@pytest.fixture(autouse=True) +def orchestration_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + ], + safe=True, + ) + yield + + +def _draft_with_rule(db, condition_tree, actions): + group = create_group() + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="Validation integration", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [ + { + "name": "Invalid rule", + "condition_tree": condition_tree, + "actions": actions, + } + ], + ) + return user, orchestration, draft + + +def test_invalid_condition_blocks_publication(db): + user, orchestration, draft = _draft_with_rule( + db, + {"field": "process.__class__", "operator": "exists"}, + [], + ) + + validation = validate_version(draft.id) + assert validation["valid"] is False + assert any("field reference" in error for error in validation["errors"]) + + with pytest.raises(OrchestrationValidationError): + publish_draft(orchestration.id, actor_id=user.id) + + +def test_invalid_template_blocks_publication(db): + user, orchestration, draft = _draft_with_rule( + db, + {}, + [{"type": "set_title", "value": "{{ event.title | attr('__class__') }}"}], + ) + + validation = validate_version(draft.id) + assert validation["valid"] is False + assert any("unsupported template filter" in error for error in validation["errors"]) + + with pytest.raises(OrchestrationValidationError): + publish_draft(orchestration.id, actor_id=user.id) diff --git a/tests/orchestration/test_orchestration_regex_security.py b/tests/orchestration/test_orchestration_regex_security.py new file mode 100644 index 0000000..284625e --- /dev/null +++ b/tests/orchestration/test_orchestration_regex_security.py @@ -0,0 +1,52 @@ +import pytest + +from app.services.orchestration.conditions import evaluate_condition_tree, validate_condition_tree +from app.services.orchestration.errors import RegexSafetyError +from app.services.orchestration.fields import build_context +from app.services.orchestration.limits import MAX_REGEX_INPUT_LENGTH, MAX_REGEX_PATTERN_LENGTH +from app.services.orchestration.regex import compile_safe_regex + + +def test_nested_quantifier_is_rejected(): + with pytest.raises(RegexSafetyError, match="nested quantified"): + compile_safe_regex(r"(a+)+$") + + +def test_quantified_alternation_is_rejected(): + with pytest.raises(RegexSafetyError, match="quantified alternation"): + compile_safe_regex(r"(a|aa)+$") + + +def test_backreference_and_lookbehind_are_rejected(): + with pytest.raises(RegexSafetyError, match="backreferences"): + compile_safe_regex(r"(a)\1") + with pytest.raises(RegexSafetyError, match="lookbehind"): + compile_safe_regex(r"(?<=a)b") + + +def test_regex_pattern_size_is_limited(): + issues = validate_condition_tree( + { + "field": "event.title", + "operator": "regex", + "value": "a" * (MAX_REGEX_PATTERN_LENGTH + 1), + } + ) + assert any(issue.code == "unsafe_regex" for issue in issues) + + +def test_regex_input_size_is_bounded_without_crashing(): + context = build_context(event={"title": "a" * (MAX_REGEX_INPUT_LENGTH + 1)}) + result = evaluate_condition_tree( + {"field": "event.title", "operator": "regex", "value": "^a+$"}, + context, + ) + assert result.matched is False + assert result.code == "unsafe_regex" + + +def test_architecture_named_group_syntax_is_supported(): + regex = compile_safe_regex(r"^\[(?[^]]+)\]") + match = regex.search("[prod] API down") + assert match is not None + assert match.groupdict() == {"environment": "prod"} diff --git a/tests/orchestration/test_orchestration_rule_validation.py b/tests/orchestration/test_orchestration_rule_validation.py new file mode 100644 index 0000000..e0675aa --- /dev/null +++ b/tests/orchestration/test_orchestration_rule_validation.py @@ -0,0 +1,24 @@ +from app.services.orchestration.validation import validate_rule_definition + + +def test_rule_validation_combines_condition_extractor_and_template_errors(): + result = validate_rule_definition( + { + "field": "unknown.root", + "operator": "regex", + "value": "(a+)+$", + }, + [ + { + "type": "set_variable", + "name": "bad-name", + "value": "{{ event.title | unsafe }}", + } + ], + ) + + codes = {issue.code for issue in result["errors"]} + assert "invalid_field_reference" in codes + assert "unsafe_regex" in codes + assert "invalid_variable_name" in codes + assert "invalid_template" in codes diff --git a/tests/orchestration/test_orchestration_templates.py b/tests/orchestration/test_orchestration_templates.py new file mode 100644 index 0000000..2cab229 --- /dev/null +++ b/tests/orchestration/test_orchestration_templates.py @@ -0,0 +1,67 @@ +import pytest + +from app.services.orchestration.errors import TemplateValidationError +from app.services.orchestration.fields import build_context +from app.services.orchestration.limits import MAX_TEMPLATE_LENGTH +from app.services.orchestration.templates import render_template, validate_template + + +@pytest.fixture +def context(): + return build_context( + event={"title": " API DOWN ", "labels": {"environment": "PROD"}}, + variables={"service": "payments-api"}, + ) + + +def test_restricted_template_renders_whitelisted_filters(context): + rendered = render_template( + "{{ labels.environment | lower }}: {{ event.title | trim | replace('DOWN', 'degraded') }} / {{ variables.service | truncate(8) }}", + context, + ) + assert rendered.value == "prod: API degraded / payments" + assert rendered.references == ( + "labels.environment", + "event.title", + "variables.service", + ) + + +def test_default_filter_handles_missing_field(context): + rendered = render_template( + "owner={{ labels.owner | default('unknown') | upper }}", + context, + ) + assert rendered.value == "owner=UNKNOWN" + + +def test_missing_field_without_default_fails(context): + with pytest.raises(TemplateValidationError, match="does not exist"): + render_template("{{ labels.owner }}", context) + + +@pytest.mark.parametrize( + "template", + [ + "{{ event.title.__class__ }}", + "{{ event.title | attr('__class__') }}", + "{{ __import__('os').system('id') }}", + "{{ event.title | lower() | unknown }}", + "{{ event.title | replace(open('/tmp/x'), 'x') }}", + ], +) +def test_arbitrary_expressions_and_unknown_filters_are_rejected(template): + assert validate_template(template) + + +def test_unbalanced_template_is_rejected(): + issues = validate_template("{{ event.title") + assert any(issue.code == "unbalanced_template_delimiter" for issue in issues) + + +def test_template_size_and_output_are_limited(context): + issues = validate_template("x" * (MAX_TEMPLATE_LENGTH + 1)) + assert any(issue.code == "template_size_limit" for issue in issues) + + with pytest.raises(TemplateValidationError, match="output limit"): + render_template("{{ event.title }}", context, max_output_length=2) diff --git a/tests/orchestration/test_orchestration_variables.py b/tests/orchestration/test_orchestration_variables.py new file mode 100644 index 0000000..a6c2136 --- /dev/null +++ b/tests/orchestration/test_orchestration_variables.py @@ -0,0 +1,172 @@ +import pytest + +from app.services.orchestration.errors import ExtractionError +from app.services.orchestration.fields import build_context +from app.services.orchestration.variables import extract_variables, validate_extractors + + +@pytest.fixture +def context(): + return build_context( + event={ + "title": "[PROD][payments] latency high", + "message": "owner:sre:primary", + "labels": {"region": "EU-CENTRAL-1"}, + }, + raw={ + "alert": { + "targets": [ + {"name": "api"}, + {"name": "worker"}, + ] + } + }, + ) + + +def test_all_required_variable_extraction_modes(context): + result = extract_variables( + [ + { + "type": "extract_regex", + "source": "event.title", + "pattern": r"^\[(?[^]]+)\]\[(?[^]]+)\]", + }, + { + "type": "copy_field", + "source": "labels.region", + "name": "region_raw", + }, + { + "type": "json_path", + "source": "raw", + "path": "$.alert.targets[1].name", + "name": "second_target", + }, + { + "type": "split", + "source": "event.message", + "delimiter": ":", + "targets": ["kind", "owner", "level"], + }, + { + "type": "set_variable", + "name": "summary", + "value": "{{ variables.environment | lower }}-{{ variables.service }}", + }, + { + "type": "lowercase", + "source": "variables.region_raw", + "name": "region", + }, + ], + context, + ) + + assert result.outcome == "continue" + assert result.variables == { + "environment": "PROD", + "service": "payments", + "region_raw": "EU-CENTRAL-1", + "second_target": "worker", + "kind": "owner", + "owner": "sre", + "level": "primary", + "summary": "prod-payments", + "region": "eu-central-1", + } + assert all(step.success for step in result.steps) + + +def test_extraction_does_not_mutate_input_context(context): + original = dict(context["variables"]) + result = extract_variables( + [{"type": "static", "name": "new_value", "value": "x"}], context + ) + assert result.variables["new_value"] == "x" + assert context["variables"] == original + + +def test_failure_modes_are_structured(context): + extractors = [ + { + "type": "copy_field", + "source": "labels.missing", + "name": "missing", + "on_failure": "continue", + }, + { + "type": "json_path", + "source": "raw", + "path": "$.does.not.exist", + "name": "bad", + "on_failure": "stop_rule", + }, + {"type": "static", "name": "unreachable", "value": "x"}, + ] + + result = extract_variables(extractors, context) + + assert result.outcome == "stop_rule" + assert len(result.steps) == 2 + assert result.steps[0].success is False + assert result.steps[1].failure_mode == "stop_rule" + assert "unreachable" not in result.variables + + +def test_invalid_json_path_and_variable_name_fail_validation(): + issues = validate_extractors( + [ + { + "type": "json_path", + "source": "raw", + "path": "$..secret", + "name": "bad-name", + } + ] + ) + codes = {issue.code for issue in issues} + assert "invalid_json_path" in codes + assert "invalid_variable_name" in codes + + +def test_invalid_extractor_cannot_execute(context): + with pytest.raises(ExtractionError, match="unsupported variable extractor"): + extract_variables( + [{"type": "python", "code": "__import__('os').system('id')"}], + context, + ) + + +def test_regex_extractor_requires_valid_targets(): + issues = validate_extractors( + [{"type": "extract_regex", "source": "event.title", "pattern": "^ok$"}] + ) + assert any(issue.code == "missing_regex_targets" for issue in issues) + + issues = validate_extractors( + [ + { + "type": "extract_regex", + "source": "event.title", + "pattern": r"^(?P.+)$", + "name": "target", + "group": "missing", + } + ] + ) + assert any(issue.code == "invalid_regex_group" for issue in issues) + + +def test_split_targets_must_be_unique(): + issues = validate_extractors( + [ + { + "type": "split", + "source": "event.message", + "delimiter": ":", + "targets": ["owner", "owner"], + } + ] + ) + assert any(issue.code == "duplicate_variable_name" for issue in issues) From 7ae8d7b55a570c01cf41387d5242acc5c1107398 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Tue, 21 Jul 2026 10:10:28 +0300 Subject: [PATCH 03/34] Implements deterministic action execution, event mutation, routing, grouping, disposition actions and nested rule processing. Part of #16 Closes #19 --- app/api/schemas/maintenance_windows.py | 3 +- app/login.py | 5 +- .../20260522000001_rotation_layers.py | 3 +- ...000001_oncall_shift_email_notifications.py | 5 +- ...000004_alert_groups_repair_and_backfill.py | 3 +- .../20260606000002_incident_management.py | 3 +- app/modules/common.py | 13 + app/modules/db/alerts_repo.py | 33 +- app/modules/db/business_services_repo.py | 21 +- app/modules/db/calendar_feeds_repo.py | 5 +- app/modules/db/channels_repo.py | 3 +- app/modules/db/escalation_policies_repo.py | 9 +- app/modules/db/groups_repo.py | 3 +- app/modules/db/heartbeats_repo.py | 11 +- app/modules/db/incidents_repo.py | 31 +- app/modules/db/locks_repo.py | 3 +- app/modules/db/maintenance_repo.py | 5 +- app/modules/db/matcher_presets_repo.py | 11 +- app/modules/db/models.py | 271 +++---- app/modules/db/notification_policies_repo.py | 19 +- app/modules/db/notifications_repo.py | 9 +- app/modules/db/orchestrations_repo.py | 3 +- app/modules/db/priority_policies_repo.py | 25 +- app/modules/db/rotations_repo.py | 17 +- app/modules/db/routes_repo.py | 3 +- app/modules/db/services_repo.py | 57 +- app/modules/db/silences_repo.py | 7 +- app/modules/db/sso_repo.py | 11 +- app/modules/db/teams_repo.py | 3 +- app/modules/db/tokens_repo.py | 7 +- app/modules/db/users_repo.py | 3 +- app/modules/sso/sso_login.py | 5 +- app/notifiers/browser_push/service.py | 29 +- app/notifiers/telegram/poller.py | 11 +- app/services/alerts/correlation.py | 5 +- app/services/alerts/escalation.py | 7 +- app/services/alerts/explain_cleanup.py | 3 +- app/services/alerts/lifecycle.py | 3 +- app/services/alerts/notification_queue.py | 5 +- app/services/alerts/priority.py | 3 +- app/services/alerts/reminders.py | 3 +- app/services/business_services/status.py | 7 +- app/services/caldav/auth.py | 3 +- app/services/calendar_feeds.py | 3 +- app/services/heartbeats/service.py | 3 +- app/services/incidents/manual.py | 3 +- app/services/integrations/auth.py | 3 +- app/services/notifications/delivery.py | 3 +- app/services/notifications/rules.py | 31 +- .../notifications/shift_notifications.py | 17 +- app/services/oncall.py | 11 +- app/services/oncall_health.py | 3 +- app/services/orchestration/__init__.py | 30 + app/services/orchestration/actions.py | 719 ++++++++++++++++++ app/services/orchestration/engine.py | 288 +++++++ app/services/orchestration/evaluator.py | 13 + app/services/orchestration/validation.py | 4 + app/services/scheduler.py | 19 +- app/services/serializers/tokens.py | 3 +- app/services/service_catalog/analytics.py | 3 +- .../service_catalog/impact_snapshots.py | 13 +- app/services/service_catalog/presets.py | 9 +- app/services/service_catalog/readiness.py | 3 +- app/services/service_catalog/sli_slo.py | 5 +- app/services/service_catalog/standards.py | 17 +- app/services/service_catalog/timeline.py | 3 +- app/services/user_oncall_status.py | 3 +- app/views/business_services/routes.py | 3 +- app/views/calendar_view.py | 3 +- app/views/integrations_view.py | 3 +- app/views/profile_view.py | 3 +- app/views/rotations_view.py | 3 +- app/views/services/details.py | 5 +- .../alerts/test_alert_group_notifications.py | 3 +- tests/alerts/test_alert_groups_api.py | 5 +- tests/alerts/test_alert_trace.py | 3 +- tests/alerts/test_alerts_service_extended.py | 9 +- tests/calendar/test_caldav_api.py | 5 +- tests/calendar/test_calendar_feeds.py | 3 +- tests/factories.py | 13 +- .../test_heartbeats_instance_regressions.py | 7 +- tests/heartbeats/test_heartbeats_service.py | 21 +- .../test_heartbeats_service_expanded.py | 47 +- tests/incidents/test_incidents_api.py | 3 +- .../test_browser_push_actions_group_only.py | 3 +- .../test_browser_push_notification_service.py | 3 +- .../test_browser_push_service.py | 5 +- .../test_notification_regressions.py | 13 +- .../test_notification_rules_delivery.py | 13 +- .../test_notification_rules_group_only.py | 5 +- .../test_notification_rules_service.py | 27 +- .../test_orchestration_action_validation.py | 42 + .../test_orchestration_actions.py | 199 +++++ .../test_orchestration_engine.py | 147 ++++ .../test_orchestration_versioning.py | 2 +- .../matchers/test_silence_matcher_presets.py | 3 +- tests/routes/test_reminder_interval.py | 7 +- .../test_business_service_manual_status.py | 13 +- .../test_business_services_acceptance.py | 3 +- .../test_business_services_edge_cases.py | 3 +- .../service_catalog/test_service_timeline.py | 5 +- .../test_sli_slo_evaluation.py | 19 +- tests/services/test_maintenance_windows.py | 11 +- .../services/test_service_impact_snapshots.py | 13 +- tests/services/test_services.py | 3 +- tests/test_escalation_policies.py | 7 +- tests/test_repositories_extended.py | 3 +- 107 files changed, 2069 insertions(+), 517 deletions(-) create mode 100644 app/services/orchestration/actions.py create mode 100644 app/services/orchestration/engine.py create mode 100644 tests/orchestration/test_orchestration_action_validation.py create mode 100644 tests/orchestration/test_orchestration_actions.py create mode 100644 tests/orchestration/test_orchestration_engine.py diff --git a/app/api/schemas/maintenance_windows.py b/app/api/schemas/maintenance_windows.py index f8287db..4b02496 100644 --- a/app/api/schemas/maintenance_windows.py +++ b/app/api/schemas/maintenance_windows.py @@ -7,6 +7,7 @@ from app.api.schemas.base import ApiModel from app.modules.common import as_naive_datetime +from app.modules.common import utc_now MAINTENANCE_WINDOW_NAME_MAX_LENGTH = 255 @@ -210,7 +211,7 @@ def normalize_ends_at(cls, value: datetime) -> datetime: @model_validator(mode="after") def validate_ends_at(self): - if self.ends_at <= datetime.utcnow(): + if self.ends_at <= utc_now(): raise ValueError("ends_at must be in the future") return self diff --git a/app/login.py b/app/login.py index 2fa4dd2..827d0fb 100644 --- a/app/login.py +++ b/app/login.py @@ -5,6 +5,7 @@ from werkzeug.security import check_password_hash, generate_password_hash from app.settings import Config +from app.modules.common import utc_now JWT_ALGORITHM = "HS256" @@ -62,14 +63,14 @@ def create_access_token(user): Create a JWT access token for a user. """ - expires_at = datetime.utcnow() + timedelta(minutes=Config.JWT_EXPIRE_MINUTES) + expires_at = utc_now() + timedelta(minutes=Config.JWT_EXPIRE_MINUTES) payload = { "sub": str(user.id), "username": user.username, "is_admin": bool(user.is_admin), "exp": expires_at, - "iat": datetime.utcnow(), + "iat": utc_now(), } token = jwt.encode(payload, Config.JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) diff --git a/app/migrations/20260522000001_rotation_layers.py b/app/migrations/20260522000001_rotation_layers.py index 4162c18..e0ff345 100644 --- a/app/migrations/20260522000001_rotation_layers.py +++ b/app/migrations/20260522000001_rotation_layers.py @@ -8,6 +8,7 @@ RotationLayerMember, RotationLayerRestriction, ) +from app.modules.common import utc_now db = init_database() @@ -40,7 +41,7 @@ def upgrade(): "handoff_weekday": rotation.handoff_weekday, "timezone": rotation.timezone, "enabled": rotation.enabled, - "created_at": datetime.utcnow(), + "created_at": utc_now(), }, ) diff --git a/app/migrations/20260530000001_oncall_shift_email_notifications.py b/app/migrations/20260530000001_oncall_shift_email_notifications.py index a31cf96..f2b6d4d 100644 --- a/app/migrations/20260530000001_oncall_shift_email_notifications.py +++ b/app/migrations/20260530000001_oncall_shift_email_notifications.py @@ -14,6 +14,7 @@ from app.db import init_database from app.modules.db.migrator import get_migrator from app.modules.db.models import BaseModel, Rotation, User +from app.modules.common import utc_now db = init_database() @@ -55,9 +56,9 @@ class OnCallShiftEmailNotificationMigration(BaseModel): status = CharField(default="pending", index=True) last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_email_notification" diff --git a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py index 2dddbef..6bae483 100644 --- a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py +++ b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py @@ -15,6 +15,7 @@ AlertGroupMerge, AlertNotification, ) +from app.modules.common import utc_now db = init_database() @@ -190,7 +191,7 @@ def _backfill_alert_groups(): for alert in alerts: group_key = alert.group_key or alert.dedup_key or f"alert:{alert.id}" - now = datetime.utcnow() + now = utc_now() first_seen_at = alert.first_seen_at or now last_seen_at = alert.last_seen_at or first_seen_at status = alert.status or "firing" diff --git a/app/migrations/20260606000002_incident_management.py b/app/migrations/20260606000002_incident_management.py index df6b4ec..aaaf487 100644 --- a/app/migrations/20260606000002_incident_management.py +++ b/app/migrations/20260606000002_incident_management.py @@ -13,6 +13,7 @@ IncidentStakeholder, MaintenanceWindowScope, ) +from app.modules.common import utc_now INCIDENT_TABLE_MODELS = [ @@ -232,7 +233,7 @@ def _seed_default_priorities(): if not _table_exists("incident_priority"): return - now = datetime.utcnow() + now = utc_now() placeholder = _placeholder() select_sql = ( diff --git a/app/modules/common.py b/app/modules/common.py index da29014..49cc5e1 100644 --- a/app/modules/common.py +++ b/app/modules/common.py @@ -1,3 +1,4 @@ +import datetime as dt from datetime import datetime, timezone as dt_timezone @@ -70,3 +71,15 @@ def truncate_text(value, limit=500): return value return value[: limit - 1].rstrip() + "…" + + + +UTC = getattr(dt, "UTC", dt.timezone.utc) + + +def utc_now() -> dt.datetime: + """Return current UTC time without tzinfo. + + IncidentRelay stores timestamps as naive UTC values. + """ + return dt.datetime.now(UTC).replace(tzinfo=None) diff --git a/app/modules/db/alerts_repo.py b/app/modules/db/alerts_repo.py index 4cce192..8064acf 100644 --- a/app/modules/db/alerts_repo.py +++ b/app/modules/db/alerts_repo.py @@ -26,6 +26,7 @@ apply_field_values_filter, normalize_filter_values, ) +from app.modules.common import utc_now MAX_ALERTS_PAGE_SIZE = 100 @@ -524,7 +525,7 @@ def _collect_group_labels(alerts): def recalculate_alert_group(group): """Recalculate alert group counters, labels and effective status.""" - now = datetime.utcnow() + now = utc_now() alerts = list( Alert @@ -632,7 +633,7 @@ def acknowledge_alert_group(group_id, user_id=None): group.previous_status = group.status group.status = "acknowledged" group.acknowledged_by = user_id - group.acknowledged_at = datetime.utcnow() + group.acknowledged_at = utc_now() group.save() return group @@ -640,7 +641,7 @@ def acknowledge_alert_group(group_id, user_id=None): def resolve_alert_group(group_id, user_id=None): """Resolve alert group and all child alerts.""" - now = datetime.utcnow() + now = utc_now() group = AlertGroup.get_by_id(group_id) ( @@ -709,7 +710,7 @@ def merge_alert_groups(target_group_id, source_group_ids, user_id=None, reason=N """Merge source groups into target group.""" target = AlertGroup.get_by_id(target_group_id) - now = datetime.utcnow() + now = utc_now() for source_id in source_group_ids: if int(source_id) == int(target_group_id): @@ -836,12 +837,12 @@ def update_alert_from_payload(alert, alert_data, status, group_key): """ Update an alert from normalized payload data. """ - now = datetime.utcnow() + now = utc_now() alert.last_seen_at = now previous_status = alert.status alert.previous_status = previous_status - alert.last_seen_at = datetime.utcnow() + alert.last_seen_at = utc_now() alert.payload = alert_data.get("payload") alert.labels = alert_data.get("labels") alert.message = alert_data.get("message") @@ -894,7 +895,7 @@ def schedule_alert_group_notification(group, due_at, reason="notification"): group.notification_pending = True group.notification_due_at = due_at group.notification_reason = reason - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save() return group @@ -906,7 +907,7 @@ def clear_alert_group_notification(group): group.notification_pending = False group.notification_due_at = None group.notification_reason = None - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save() return group @@ -915,7 +916,7 @@ def clear_alert_group_notification(group): def mark_alert_group_notification_sent(group, now=None): """Mark group notification as sent.""" - now = now or datetime.utcnow() + now = now or utc_now() group.notification_pending = False group.notification_due_at = None @@ -930,7 +931,7 @@ def mark_alert_group_notification_sent(group, now=None): def list_due_alert_group_notifications(now=None, limit=100): """Return groups with due pending notifications.""" - now = now or datetime.utcnow() + now = now or utc_now() return list( AlertGroup @@ -962,8 +963,8 @@ def create_alert_comment( alert=alert_id, user=user_id, body=body, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -1016,8 +1017,8 @@ def soft_delete_alert_comment(comment_id: int) -> bool: AlertComment .update( deleted=True, - deleted_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + deleted_at=utc_now(), + updated_at=utc_now(), ) .where( AlertComment.id == comment_id, @@ -1043,7 +1044,7 @@ def update_alert_comment( return None comment.body = body - comment.updated_at = datetime.utcnow() + comment.updated_at = utc_now() comment.save(only=[ AlertComment.body, AlertComment.updated_at, @@ -1160,7 +1161,7 @@ def finish_alert_explain_trace( trace.outcome = outcome trace.reason = reason trace.result = result or {} - trace.finished_at = finished_at or datetime.utcnow() + trace.finished_at = finished_at or utc_now() trace.save( only=[ diff --git a/app/modules/db/business_services_repo.py b/app/modules/db/business_services_repo.py index e16acf7..21ba120 100644 --- a/app/modules/db/business_services_repo.py +++ b/app/modules/db/business_services_repo.py @@ -9,6 +9,7 @@ BusinessServiceStatusHistory, Service, ) +from app.modules.common import utc_now def set_business_service_manual_status( @@ -18,7 +19,7 @@ def set_business_service_manual_status( until=None, user_id=None, ): - now = datetime.utcnow() + now = utc_now() BusinessService.update( manual_status=manual_status, @@ -33,7 +34,7 @@ def set_business_service_manual_status( def clear_business_service_manual_status(business_service_id): - now = datetime.utcnow() + now = utc_now() BusinessService.update( manual_status=None, @@ -91,7 +92,7 @@ def list_business_services(group_id=None, public_only=False, active_only=True): def create_business_service(data): data = dict(data) - now = datetime.utcnow() + now = utc_now() data.setdefault("status", "unknown") data.setdefault("status_source", "calculated") data.setdefault("created_at", now) @@ -105,7 +106,7 @@ def update_business_service(business_service_id, data): for field, value in data.items(): setattr(business_service, field, value) - business_service.updated_at = datetime.utcnow() + business_service.updated_at = utc_now() business_service.save() return business_service @@ -113,7 +114,7 @@ def update_business_service(business_service_id, data): def soft_delete_business_service(business_service_id): business_service = get_business_service(business_service_id) - now = datetime.utcnow() + now = utc_now() business_service.deleted = True business_service.deleted_at = now business_service.enabled = False @@ -192,7 +193,7 @@ def list_components_for_service(service_id, active_only=True): def create_business_service_component(business_service_id, data): data = dict(data) - now = datetime.utcnow() + now = utc_now() data["business_service"] = business_service_id data.setdefault("created_at", now) data["updated_at"] = now @@ -205,7 +206,7 @@ def update_business_service_component(component_id, data): for field, value in data.items(): setattr(component, field, value) - component.updated_at = datetime.utcnow() + component.updated_at = utc_now() component.save() return component @@ -254,7 +255,7 @@ def count_components_by_business_service_ids(business_service_ids, active_only=T def soft_delete_business_service_component(component_id): component = BusinessServiceComponent.get_by_id(component_id) - now = datetime.utcnow() + now = utc_now() component.deleted = True component.deleted_at = now component.enabled = False @@ -313,7 +314,7 @@ def upsert_incident_impact( reason=None, component_snapshot=None, ): - now = datetime.utcnow() + now = utc_now() impact, created = BusinessServiceIncidentImpact.get_or_create( business_service=business_service.id, @@ -349,7 +350,7 @@ def upsert_incident_impact( def deactivate_incident_impacts_for_group(group_id): - now = datetime.utcnow() + now = utc_now() return ( BusinessServiceIncidentImpact diff --git a/app/modules/db/calendar_feeds_repo.py b/app/modules/db/calendar_feeds_repo.py index e89c13f..22c7b95 100644 --- a/app/modules/db/calendar_feeds_repo.py +++ b/app/modules/db/calendar_feeds_repo.py @@ -3,6 +3,7 @@ from datetime import datetime from app.modules.db.models import CalendarFeed +from app.modules.common import utc_now def list_calendar_feeds(team_id): @@ -62,13 +63,13 @@ def update_calendar_feed(feed, **fields): def mark_calendar_feed_used(feed): - feed.last_used_at = datetime.utcnow() + feed.last_used_at = utc_now() feed.save(only=[CalendarFeed.last_used_at]) def soft_delete_calendar_feed(feed): feed.deleted = True - feed.deleted_at = datetime.utcnow() + feed.deleted_at = utc_now() feed.enabled = False feed.save( only=[ diff --git a/app/modules/db/channels_repo.py b/app/modules/db/channels_repo.py index 064781e..5681af6 100644 --- a/app/modules/db/channels_repo.py +++ b/app/modules/db/channels_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertRouteChannel, Group, NotificationChannel, Team +from app.modules.common import utc_now def list_channels( @@ -154,7 +155,7 @@ def soft_delete_channel(channel_id): channel = get_channel(channel_id) channel.enabled = False channel.deleted = True - channel.deleted_at = datetime.utcnow() + channel.deleted_at = utc_now() channel.save() return channel diff --git a/app/modules/db/escalation_policies_repo.py b/app/modules/db/escalation_policies_repo.py index 3e19eca..7797ccd 100644 --- a/app/modules/db/escalation_policies_repo.py +++ b/app/modules/db/escalation_policies_repo.py @@ -10,6 +10,7 @@ TeamUser, User, ) +from app.modules.common import utc_now def list_policies(team_id=None, team_ids=None, enabled_only=False, include_deleted=False): @@ -74,7 +75,7 @@ def update_policy(policy_id, data): if field in data: setattr(policy, field, data[field]) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -84,8 +85,8 @@ def soft_delete_policy(policy_id): policy = get_policy(policy_id) policy.enabled = False policy.deleted = True - policy.deleted_at = datetime.utcnow() - policy.updated_at = datetime.utcnow() + policy.deleted_at = utc_now() + policy.updated_at = utc_now() policy.save() return policy @@ -194,7 +195,7 @@ def update_rule(rule_id, data): elif rule.target_type == "user": rule.target_user = target_id - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule diff --git a/app/modules/db/groups_repo.py b/app/modules/db/groups_repo.py index 02121ef..dd47c9c 100644 --- a/app/modules/db/groups_repo.py +++ b/app/modules/db/groups_repo.py @@ -23,6 +23,7 @@ User, UserGroup, ) +from app.modules.common import utc_now def list_groups(active_only=False, include_deleted=False): @@ -172,7 +173,7 @@ def soft_delete_group(group_id): - users are not deleted; - group memberships are disabled. """ - now = datetime.utcnow() + now = utc_now() with db.atomic(): group = get_group(group_id) group.deleted = True diff --git a/app/modules/db/heartbeats_repo.py b/app/modules/db/heartbeats_repo.py index d6d7617..d7f8f17 100644 --- a/app/modules/db/heartbeats_repo.py +++ b/app/modules/db/heartbeats_repo.py @@ -4,6 +4,7 @@ from app.modules.db.models import Group, Heartbeat, HeartbeatInstance, HeartbeatPing, Service, Team from app.services.integrations.auth import hash_token +from app.modules.common import utc_now def list_heartbeats( @@ -192,13 +193,13 @@ def update_heartbeat(heartbeat, data): for key, value in data.items(): setattr(heartbeat, key, value) - heartbeat.updated_at = datetime.utcnow() + heartbeat.updated_at = utc_now() heartbeat.save() return heartbeat def soft_delete_heartbeat(heartbeat): - now = datetime.utcnow() + now = utc_now() heartbeat.deleted = True heartbeat.deleted_at = now heartbeat.enabled = False @@ -232,7 +233,7 @@ def record_ping( remote_addr=remote_addr, user_agent=user_agent, alert_group=alert_group_id, - received_at=received_at or datetime.utcnow(), + received_at=received_at or utc_now(), ) @@ -278,7 +279,7 @@ def create_heartbeat_instance(heartbeat, instance_key, **data): def update_heartbeat_instance(instance, data): for key, value in data.items(): setattr(instance, key, value) - instance.updated_at = datetime.utcnow() + instance.updated_at = utc_now() instance.save() return instance @@ -290,6 +291,6 @@ def delete_heartbeat_instance(instance): def disable_heartbeat_instance(instance, status="paused"): instance.enabled = False instance.status = status - instance.updated_at = datetime.utcnow() + instance.updated_at = utc_now() instance.save() return instance diff --git a/app/modules/db/incidents_repo.py b/app/modules/db/incidents_repo.py index 8bae181..e8ff5a0 100644 --- a/app/modules/db/incidents_repo.py +++ b/app/modules/db/incidents_repo.py @@ -8,6 +8,7 @@ ServiceOwner, ) from app.modules.db import alerts_repo +from app.modules.common import utc_now DEFAULT_PRIORITY_SLUG = "p3" @@ -142,8 +143,8 @@ def set_incident_priority(group_id, priority_slug, *, user_id=None, manual=True) group.priority_order = priority.level group.priority_set_manually = manual group.priority_set_by = user_id - group.priority_set_at = datetime.utcnow() - group.updated_at = datetime.utcnow() + group.priority_set_at = utc_now() + group.updated_at = utc_now() group.save(only=[ AlertGroup.priority, @@ -180,7 +181,7 @@ def reset_incident_priority(group_id, priority, *, update_mode=None): group.priority_set_manually = False group.priority_set_by = None group.priority_set_at = None - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save(only=[ AlertGroup.priority, @@ -242,7 +243,7 @@ def update_incident_responder_notification( .update( notification_status=status, notification_error=error, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where(IncidentResponder.id == responder_id) .execute() @@ -258,7 +259,7 @@ def create_incident_responder(group_id, data): expires_at = data.get("expires_at") if not expires_at and data.get("expires_after_minutes"): - expires_at = datetime.utcnow() + timedelta( + expires_at = utc_now() + timedelta( minutes=int(data["expires_after_minutes"]) ) @@ -275,10 +276,10 @@ def create_incident_responder(group_id, data): response_message=data.get("response_message"), notification_status=data.get("notification_status") or "pending", notification_error=data.get("notification_error"), - requested_at=datetime.utcnow(), + requested_at=utc_now(), expires_at=expires_at, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -309,8 +310,8 @@ def update_incident_responder_status( responder.status = status responder.response_message = response_message - responder.responded_at = datetime.utcnow() - responder.updated_at = datetime.utcnow() + responder.responded_at = utc_now() + responder.updated_at = utc_now() save_fields = [ IncidentResponder.status, @@ -333,7 +334,7 @@ def update_incident_responder_status( def list_expired_requested_responders(*, now=None, limit=100): """Return requested responder rows whose expiration time has passed.""" - now = now or datetime.utcnow() + now = now or utc_now() return list( IncidentResponder @@ -353,7 +354,7 @@ def list_expired_requested_responders(*, now=None, limit=100): def expire_incident_responder(responder_id, *, now=None): """Expire one responder request only if it is still requested.""" - now = now or datetime.utcnow() + now = now or utc_now() updated = ( IncidentResponder @@ -393,8 +394,8 @@ def create_incident_stakeholder(group_id, data): notify_on_comment=data.get("notify_on_comment", True), active=data.get("active", True), created_by=data.get("created_by_id"), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -426,7 +427,7 @@ def deactivate_incident_stakeholder(stakeholder_id): IncidentStakeholder .update( active=False, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( IncidentStakeholder.id == stakeholder_id, diff --git a/app/modules/db/locks_repo.py b/app/modules/db/locks_repo.py index 4665cf0..aaa65f2 100644 --- a/app/modules/db/locks_repo.py +++ b/app/modules/db/locks_repo.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import AppLock +from app.modules.common import utc_now def acquire_lock(name, owner, ttl_seconds): @@ -10,7 +11,7 @@ def acquire_lock(name, owner, ttl_seconds): Acquire a database-backed lock. """ - now = datetime.utcnow() + now = utc_now() expires_at = now + timedelta(seconds=ttl_seconds) try: diff --git a/app/modules/db/maintenance_repo.py b/app/modules/db/maintenance_repo.py index 84ba8be..5656334 100644 --- a/app/modules/db/maintenance_repo.py +++ b/app/modules/db/maintenance_repo.py @@ -9,6 +9,7 @@ MaintenanceWindowScope, ) from app.modules.common import as_naive_datetime +from app.modules.common import utc_now ACTIVE_STATUSES = ("scheduled", "active") @@ -364,7 +365,7 @@ def cancel_maintenance_window(window, *, cancelled_by=None, reason=None): window.status = "cancelled" window.enabled = False window.cancelled_by = cancelled_by - window.cancelled_at = datetime.utcnow() + window.cancelled_at = utc_now() window.cancel_reason = str(reason or "").strip() or None window.save() @@ -390,7 +391,7 @@ def replace_maintenance_window_scopes(window_id, scopes): team=scope.get("team_id"), service=scope.get("service_id"), route=scope.get("route_id"), - created_at=datetime.utcnow(), + created_at=utc_now(), ) ) diff --git a/app/modules/db/matcher_presets_repo.py b/app/modules/db/matcher_presets_repo.py index c744f1e..7eea941 100644 --- a/app/modules/db/matcher_presets_repo.py +++ b/app/modules/db/matcher_presets_repo.py @@ -12,6 +12,7 @@ Silence, ServiceRunbook, ) +from app.modules.common import utc_now def count_service_runbook_usages(preset_id): @@ -164,8 +165,8 @@ def create_matcher_preset( matchers=matchers or {}, enabled=enabled, version=1, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -187,7 +188,7 @@ def restore_matcher_preset( preset.version = int(preset.version or 0) + 1 preset.deleted = False preset.deleted_at = None - preset.updated_at = datetime.utcnow() + preset.updated_at = utc_now() preset.save() return preset @@ -209,7 +210,7 @@ def update_matcher_preset(preset_id, data): if field in allowed_fields: setattr(preset, field, value) - preset.updated_at = datetime.utcnow() + preset.updated_at = utc_now() preset.save() return preset @@ -315,7 +316,7 @@ def list_matcher_preset_usages(preset_id): def soft_delete_matcher_preset(preset_id): """Soft-delete a matcher preset.""" preset = get_matcher_preset(preset_id) - now = datetime.utcnow() + now = utc_now() preset.enabled = False preset.deleted = True diff --git a/app/modules/db/models.py b/app/modules/db/models.py index 794ebf2..d870024 100644 --- a/app/modules/db/models.py +++ b/app/modules/db/models.py @@ -16,6 +16,7 @@ ) from app.db import database_proxy +from app.modules.common import utc_now class JSONTextField(TextField): @@ -56,7 +57,7 @@ class Migration(BaseModel): id = AutoField() name = CharField(unique=True) - applied_at = DateTimeField(default=datetime.utcnow) + applied_at = DateTimeField(default=utc_now) class MigrationState(BaseModel): @@ -66,7 +67,7 @@ class MigrationState(BaseModel): version = IntegerField(unique=True) name = CharField() service_version = CharField(null=True) - applied_at = DateTimeField(default=datetime.utcnow) + applied_at = DateTimeField(default=utc_now) class Group(SoftDeleteModel): @@ -77,7 +78,7 @@ class Group(SoftDeleteModel): name = CharField() description = TextField(null=True) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_group" @@ -94,7 +95,7 @@ class Team(SoftDeleteModel): escalation_enabled = BooleanField(default=True) escalation_after_reminders = IntegerField(default=2) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class MatcherPreset(SoftDeleteModel): @@ -115,8 +116,8 @@ class MatcherPreset(SoftDeleteModel): enabled = BooleanField(default=True, index=True) version = IntegerField(default=1) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "matcher_preset" @@ -145,7 +146,7 @@ class User(SoftDeleteModel): active = BooleanField(default=True) is_admin = BooleanField(default=False) active_group = ForeignKeyField(Group, null=True, backref="active_users", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class UserGroup(BaseModel): @@ -162,7 +163,7 @@ class UserGroup(BaseModel): group = ForeignKeyField(Group, backref="user_memberships", on_delete="CASCADE") role = CharField(default="viewer") active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -177,7 +178,7 @@ class Role(BaseModel): name = CharField(unique=True) description = TextField(null=True) permissions = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class UserRole(BaseModel): @@ -187,7 +188,7 @@ class UserRole(BaseModel): user = ForeignKeyField(User, backref="role_assignments", on_delete="CASCADE") role = ForeignKeyField(Role, backref="user_assignments", on_delete="CASCADE") team = ForeignKeyField(Team, null=True, backref="role_assignments", on_delete="CASCADE") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -209,7 +210,7 @@ class TeamUser(BaseModel): user = ForeignKeyField(User, backref="team_memberships", on_delete="CASCADE") role = CharField(default="viewer") active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -234,7 +235,7 @@ class Rotation(SoftDeleteModel): handoff_weekday = IntegerField(null=True) timezone = CharField(default="UTC") enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation" @@ -265,7 +266,7 @@ class RotationOverride(BaseModel): starts_at = DateTimeField() ends_at = DateTimeField() reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class NotificationChannel(SoftDeleteModel): @@ -278,7 +279,7 @@ class NotificationChannel(SoftDeleteModel): channel_type = CharField() config = JSONTextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -295,8 +296,8 @@ class EscalationPolicy(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True) repeat_count = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "escalation_policy" @@ -326,8 +327,8 @@ class EscalationPolicyRule(BaseModel): on_delete="SET NULL", ) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "escalation_policy_rule" @@ -352,8 +353,8 @@ class NotificationPolicy(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy" @@ -391,8 +392,8 @@ class NotificationPolicyRule(SoftDeleteModel): continue_matching = BooleanField(default=False) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy_rule" @@ -419,7 +420,7 @@ class NotificationPolicyRuleChannel(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "notification_policy_rule_channel" @@ -494,8 +495,8 @@ class Service(SoftDeleteModel): public_description = TextField(null=True) public_order = IntegerField(default=100) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service" @@ -518,7 +519,7 @@ class ServiceChannel(BaseModel): ) purpose = CharField(default="default") enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_channel" @@ -540,8 +541,8 @@ class ServiceDependency(SoftDeleteModel): description = TextField(null=True) metadata = JSONTextField(default=dict) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_dependency" @@ -585,8 +586,8 @@ class BusinessService(SoftDeleteModel): metadata = JSONTextField(default=dict) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service" @@ -615,8 +616,8 @@ class BusinessServiceComponent(SoftDeleteModel): description = TextField(null=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service_component" @@ -639,7 +640,7 @@ class BusinessServiceStatusHistory(BaseModel): message = TextField(null=True) impact_score = IntegerField(default=0) component_snapshot = JSONTextField(default=list) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "business_service_status_history" @@ -665,9 +666,9 @@ class BusinessServiceIncidentImpact(BaseModel): component_snapshot = JSONTextField(default=list) - first_seen_at = DateTimeField(default=datetime.utcnow, index=True) - last_seen_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now, index=True) + last_seen_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "business_service_incident_impact" @@ -736,10 +737,10 @@ class AlertGroupCorrelation(BaseModel): reason = TextField(null=True) active = BooleanField(default=True, index=True) - first_seen_at = DateTimeField(default=datetime.utcnow, index=True) - last_seen_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) - created_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now, index=True) + last_seen_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + created_at = DateTimeField(default=utc_now) context = JSONTextField(default=dict) @@ -776,8 +777,8 @@ class ServiceEvent(BaseModel): actor_label = CharField(null=True) severity = CharField(max_length=32, null=True) status = CharField(max_length=32, null=True) - occurred_at = DateTimeField(default=datetime.utcnow, index=True) - recorded_at = DateTimeField(default=datetime.utcnow) + occurred_at = DateTimeField(default=utc_now, index=True) + recorded_at = DateTimeField(default=utc_now) schema_version = IntegerField(default=1) payload = JSONTextField(default=dict) @@ -806,7 +807,7 @@ class ServiceImpactSnapshot(BaseModel): source = CharField(default="manual", index=True) scope = CharField(default="all", index=True) - captured_at = DateTimeField(default=datetime.utcnow, index=True) + captured_at = DateTimeField(default=utc_now, index=True) max_depth = IntegerField(default=5) include_disabled = BooleanField(default=False) @@ -835,7 +836,7 @@ class ServiceImpactSnapshot(BaseModel): filters = JSONTextField(default=dict) payload = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_impact_snapshot" @@ -889,7 +890,7 @@ class ServiceImpactSnapshotItem(BaseModel): blast_radius = JSONTextField(null=True) payload = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_impact_snapshot_item" @@ -914,8 +915,8 @@ class ServiceStandard(SoftDeleteModel): applies_to = JSONTextField(default=dict) enabled = BooleanField(default=True) created_by = ForeignKeyField(User, null=True, backref="created_service_standards", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_standard" @@ -941,8 +942,8 @@ class ServiceStandardCheck(SoftDeleteModel): required = BooleanField(default=True) enabled = BooleanField(default=True) position = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_standard_check" @@ -972,7 +973,7 @@ class ServiceReadinessEvaluation(BaseModel): trigger = CharField(default="system") actor_user = ForeignKeyField(User, null=True, backref="service_readiness_evaluations", on_delete="SET NULL") content_hash = CharField(null=True, index=True) - evaluated_at = DateTimeField(default=datetime.utcnow, index=True) + evaluated_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "service_readiness_evaluation" @@ -1000,7 +1001,7 @@ class ServiceReadinessCheckResult(BaseModel): required = BooleanField(default=True) message = TextField(null=True) details = JSONTextField(default=dict) - evaluated_at = DateTimeField(default=datetime.utcnow) + evaluated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_readiness_check_result" @@ -1024,7 +1025,7 @@ class ServiceReadinessState(BaseModel): failed_required_count = IntegerField(default=0) failed_critical_count = IntegerField(default=0) content_hash = CharField(null=True, index=True) - evaluated_at = DateTimeField(default=datetime.utcnow, index=True) + evaluated_at = DateTimeField(default=utc_now, index=True) class Meta: table_name = "service_readiness_state" @@ -1055,8 +1056,8 @@ class ServiceRunbook(SoftDeleteModel): priority = IntegerField(default=100) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_runbook" @@ -1079,8 +1080,8 @@ class ServiceLink(SoftDeleteModel): priority = IntegerField(default=100) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_link" @@ -1105,8 +1106,8 @@ class IncidentPriority(BaseModel): enabled = BooleanField(default=True, index=True) default = BooleanField(default=False, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_priority" @@ -1143,8 +1144,8 @@ class PriorityPolicy(SoftDeleteModel): on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "priority_policy" @@ -1186,8 +1187,8 @@ class PriorityPolicyRule(SoftDeleteModel): enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "priority_policy_rule" @@ -1210,7 +1211,7 @@ class ServiceOwner(BaseModel): notify_on_status_change = BooleanField(default=True) notify_on_resolved = BooleanField(default=True) notify_on_comment = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "service_owner" @@ -1237,8 +1238,8 @@ class ServiceSli(SoftDeleteModel): priority = CharField(null=True, index=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_sli" @@ -1269,8 +1270,8 @@ class ServiceSlo(SoftDeleteModel): include_open_alerts = BooleanField(default=True) enabled = BooleanField(default=True, index=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_slo" @@ -1309,7 +1310,7 @@ class ServiceSloMeasurement(BaseModel): budget_consumed_seconds = IntegerField(null=True) budget_remaining_seconds = IntegerField(null=True) - calculated_at = DateTimeField(default=datetime.utcnow, index=True) + calculated_at = DateTimeField(default=utc_now, index=True) details = JSONTextField(default=dict) class Meta: @@ -1382,8 +1383,8 @@ class Heartbeat(SoftDeleteModel): metadata = JSONTextField(default=dict) created_by = ForeignKeyField(User, null=True, backref="created_heartbeats", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "heartbeat" @@ -1427,8 +1428,8 @@ class HeartbeatInstance(BaseModel): ) metadata = JSONTextField(default=dict) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "heartbeat_instance" @@ -1445,7 +1446,7 @@ class HeartbeatPing(BaseModel): id = AutoField() heartbeat = ForeignKeyField(Heartbeat, backref="pings", on_delete="CASCADE") - received_at = DateTimeField(default=datetime.utcnow, index=True) + received_at = DateTimeField(default=utc_now, index=True) event_type = CharField(default="ping", index=True) instance_key = CharField(null=True, index=True) status_before = CharField(null=True) @@ -1494,7 +1495,7 @@ class AlertRoute(SoftDeleteModel): intake_token_prefix = CharField(null=True, index=True) intake_token_hash = CharField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) service = ForeignKeyField( Service, null=True, @@ -1556,8 +1557,8 @@ class MaintenanceWindow(SoftDeleteModel): cancelled_at = DateTimeField(null=True) cancel_reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window" @@ -1585,7 +1586,7 @@ class MaintenanceWindowService(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window_service" @@ -1631,7 +1632,7 @@ class MaintenanceWindowScope(BaseModel): on_delete="CASCADE", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "maintenance_window_scope" @@ -1669,8 +1670,8 @@ class ServiceMatchRule(SoftDeleteModel): matchers = JSONTextField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "service_match_rule" @@ -1755,8 +1756,8 @@ class AlertGroup(BaseModel): ) resolved_at = DateTimeField(null=True) - first_seen_at = DateTimeField(default=datetime.utcnow) - last_seen_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now) + last_seen_at = DateTimeField(default=utc_now) last_notification_at = DateTimeField(null=True) notification_due_at = DateTimeField(null=True, index=True) notification_pending = BooleanField(default=False, index=True) @@ -1786,8 +1787,8 @@ class AlertGroup(BaseModel): merged_at = DateTimeField(null=True) merge_reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) priority = ForeignKeyField( IncidentPriority, @@ -1885,8 +1886,8 @@ class Alert(BaseModel): previous_status = CharField(null=True) acknowledged_by = ForeignKeyField(User, null=True, backref="acknowledged_alerts", on_delete="SET NULL") acknowledged_at = DateTimeField(null=True) - first_seen_at = DateTimeField(default=datetime.utcnow) - last_seen_at = DateTimeField(default=datetime.utcnow) + first_seen_at = DateTimeField(default=utc_now) + last_seen_at = DateTimeField(default=utc_now) last_notification_at = DateTimeField(null=True) reminder_count = IntegerField(default=0) escalation_level = IntegerField(default=0) @@ -1935,8 +1936,8 @@ class AlertComment(BaseModel): body = TextField() - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) deleted = BooleanField(default=False, index=True) deleted_at = DateTimeField(null=True) @@ -2017,12 +2018,12 @@ class IncidentResponder(BaseModel): notification_status = CharField(default="pending", index=True) notification_error = TextField(null=True) - requested_at = DateTimeField(default=datetime.utcnow) + requested_at = DateTimeField(default=utc_now) responded_at = DateTimeField(null=True) expires_at = DateTimeField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_responder" @@ -2074,8 +2075,8 @@ class IncidentStakeholder(BaseModel): on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "incident_stakeholder" @@ -2096,7 +2097,7 @@ class AlertEvent(BaseModel): event_type = CharField() message = TextField(null=True) user = ForeignKeyField(User, null=True, backref="alert_events", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class AlertExplainTrace(BaseModel): @@ -2129,7 +2130,7 @@ class AlertExplainTrace(BaseModel): input_summary = JSONTextField(null=True) result = JSONTextField(null=True) - started_at = DateTimeField(default=datetime.utcnow, index=True) + started_at = DateTimeField(default=utc_now, index=True) finished_at = DateTimeField(null=True, index=True) @@ -2154,7 +2155,7 @@ class AlertExplainStep(BaseModel): message = TextField(null=True) data = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) class AlertGroupMerge(BaseModel): @@ -2165,7 +2166,7 @@ class AlertGroupMerge(BaseModel): target_group = ForeignKeyField(AlertGroup, backref="target_merges", on_delete="CASCADE") merged_by = ForeignKeyField(User, null=True, backref="alert_group_merges", on_delete="SET NULL") reason = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "alert_group_merge" @@ -2190,8 +2191,8 @@ class AlertNotification(BaseModel): provider_payload = JSONTextField(null=True) last_callback_at = DateTimeField(null=True) callback_count = IntegerField(default=0) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -2215,7 +2216,7 @@ class AlertNotificationEvent(BaseModel): action = CharField(null=True) message = TextField(null=True) payload = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: indexes = ( @@ -2244,9 +2245,9 @@ class OnCallShiftEmailNotification(BaseModel): status = CharField(default="pending", index=True) # pending | sent | failed | skipped last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_email_notification" @@ -2278,9 +2279,9 @@ class OnCallShiftMattermostNotification(BaseModel): status = CharField(default="pending", index=True) # pending | sent | failed | skipped last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) sent_at = DateTimeField(null=True) - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "oncall_shift_mattermost_notification" @@ -2308,7 +2309,7 @@ class Silence(SoftDeleteModel): starts_at = DateTimeField() ends_at = DateTimeField() created_by = ForeignKeyField(User, null=True, backref="created_silences", on_delete="SET NULL") - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) enabled = BooleanField(default=True) @@ -2325,7 +2326,7 @@ class ApiToken(SoftDeleteModel): scopes = JSONTextField(null=True) expires_at = DateTimeField(null=True) active = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_used_at = DateTimeField(null=True) @@ -2385,8 +2386,8 @@ class SsoProvider(SoftDeleteModel): extra_config = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "sso_provider" @@ -2405,7 +2406,7 @@ class SsoIdentity(BaseModel): raw_claims = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_login_at = DateTimeField(null=True) class Meta: @@ -2439,8 +2440,8 @@ class SsoGroupMapping(BaseModel): active = BooleanField(default=True) priority = IntegerField(default=100) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "sso_group_mapping" @@ -2462,7 +2463,7 @@ class AuditLog(BaseModel): object_id = IntegerField(null=True) message = TextField(null=True) data = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class AppLock(BaseModel): @@ -2472,7 +2473,7 @@ class AppLock(BaseModel): name = CharField(unique=True) owner = CharField() expires_at = DateTimeField() - updated_at = DateTimeField(default=datetime.utcnow) + updated_at = DateTimeField(default=utc_now) class RotationLayer(SoftDeleteModel): @@ -2501,7 +2502,7 @@ class RotationLayer(SoftDeleteModel): timezone = CharField(null=True) enabled = BooleanField(default=True) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation_layer" @@ -2522,7 +2523,7 @@ class RotationLayerMember(BaseModel): # Period of this membership. # Removing a user closes the period. # Re-adding the same user creates a new row. - starts_at = DateTimeField(default=datetime.utcnow, index=True) + starts_at = DateTimeField(default=utc_now, index=True) ends_at = DateTimeField(null=True, index=True) class Meta: @@ -2546,7 +2547,7 @@ class RotationLayerRestriction(BaseModel): weekday = IntegerField(null=True) start_time = CharField() end_time = CharField() - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "rotation_layer_restriction" @@ -2576,8 +2577,8 @@ class BrowserPushSubscription(BaseModel): deleted_at = DateTimeField(null=True) last_seen_at = DateTimeField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "browser_push_subscription" @@ -2605,7 +2606,7 @@ class BrowserPushActionToken(BaseModel): token_hash = CharField(max_length=128, unique=True) used_at = DateTimeField(null=True) expires_at = DateTimeField() - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) class Meta: table_name = "browser_push_action_token" @@ -2633,8 +2634,8 @@ class UserNotificationRule(SoftDeleteModel): severities = JSONTextField(null=True) event_types = JSONTextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "user_notification_rule" @@ -2684,8 +2685,8 @@ class UserNotificationDelivery(BaseModel): last_error = TextField(null=True) - created_at = DateTimeField(default=datetime.utcnow) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "user_notification_delivery" @@ -2713,7 +2714,7 @@ class CalendarFeed(SoftDeleteModel): backref="calendar_feeds", on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now) last_used_at = DateTimeField(null=True) # BEGIN EVENT ORCHESTRATION V1 MODELS @@ -2762,8 +2763,8 @@ class EventOrchestration(SoftDeleteModel): null=True, on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "event_orchestration" @@ -2796,7 +2797,7 @@ def save(self, *args, **kwargs): if int(service_group_id) != int(self.group_id): raise ValueError("Referenced service belongs to another group") - self.updated_at = datetime.utcnow() + self.updated_at = utc_now() return super().save(*args, **kwargs) @@ -2827,8 +2828,8 @@ class EventOrchestrationVersion(BaseModel): null=True, on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) published_at = DateTimeField(null=True, index=True) class Meta: @@ -2868,7 +2869,7 @@ def save(self, *args, **kwargs): if current_status in ("published", "archived") and not archive_only: raise ValueError("Published orchestration versions are immutable") - self.updated_at = datetime.utcnow() + self.updated_at = utc_now() return super().save(*args, **kwargs) def delete_instance(self, *args, **kwargs): @@ -2901,8 +2902,8 @@ class EventOrchestrationRule(BaseModel): condition_tree_json = JSONTextField(default=dict) actions_json = JSONTextField(default=list) processing_mode = CharField(max_length=32, default="continue") - created_at = DateTimeField(default=datetime.utcnow, index=True) - updated_at = DateTimeField(default=datetime.utcnow) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) class Meta: table_name = "event_orchestration_rule" @@ -2938,7 +2939,7 @@ def save(self, *args, **kwargs): if int(parent_version_id) != int(self.version_id): raise ValueError("Parent rule belongs to another version") - self.updated_at = datetime.utcnow() + self.updated_at = utc_now() return super().save(*args, **kwargs) def delete_instance(self, *args, **kwargs): @@ -2966,7 +2967,7 @@ class OrchestrationIntakeToken(BaseModel): null=True, on_delete="SET NULL", ) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) last_used_at = DateTimeField(null=True) revoked_at = DateTimeField(null=True, index=True) @@ -3012,7 +3013,7 @@ class OrchestrationExecution(BaseModel): # readable even if an alert or alert group is later removed. alert_id = IntegerField(null=True, index=True) alert_group_id = IntegerField(null=True, index=True) - created_at = DateTimeField(default=datetime.utcnow, index=True) + created_at = DateTimeField(default=utc_now, index=True) expires_at = DateTimeField(null=True, index=True) class Meta: diff --git a/app/modules/db/notification_policies_repo.py b/app/modules/db/notification_policies_repo.py index b89e687..679bc75 100644 --- a/app/modules/db/notification_policies_repo.py +++ b/app/modules/db/notification_policies_repo.py @@ -11,6 +11,7 @@ Service, Team, ) +from app.modules.common import utc_now def list_notification_policies( @@ -133,7 +134,7 @@ def create_notification_policy( enabled=True, ): """Create notification policy.""" - now = datetime.utcnow() + now = utc_now() return NotificationPolicy.create( team=team_id, @@ -163,7 +164,7 @@ def restore_notification_policy( policy.enabled = enabled policy.deleted = False policy.deleted_at = None - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -181,7 +182,7 @@ def update_notification_policy(policy_id, data): if field in data: setattr(policy, field, data[field]) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -190,7 +191,7 @@ def update_notification_policy(policy_id, data): def soft_delete_notification_policy(policy_id): """Soft-delete policy and its active rules.""" policy = get_notification_policy(policy_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): ( @@ -352,7 +353,7 @@ def create_policy_rule( enabled=True, ): """Create notification policy rule.""" - now = datetime.utcnow() + now = utc_now() if position is None: position = get_next_rule_position(policy_id) @@ -389,7 +390,7 @@ def update_policy_rule(rule_id, data): if field in data: setattr(rule, field, data[field]) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -398,7 +399,7 @@ def update_policy_rule(rule_id, data): def soft_delete_policy_rule(rule_id): """Soft-delete notification policy rule.""" rule = get_policy_rule(rule_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): ( @@ -513,7 +514,7 @@ def reorder_policy_rules(policy_id, ordered_rule_ids): NotificationPolicyRule .update( position=temporary_base + index, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (NotificationPolicyRule.id == rule_id) @@ -531,7 +532,7 @@ def reorder_policy_rules(policy_id, ordered_rule_ids): NotificationPolicyRule .update( position=position, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (NotificationPolicyRule.id == rule_id) diff --git a/app/modules/db/notifications_repo.py b/app/modules/db/notifications_repo.py index 13dc3fe..9ee7f6f 100644 --- a/app/modules/db/notifications_repo.py +++ b/app/modules/db/notifications_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertNotification, AlertNotificationEvent +from app.modules.common import utc_now def get_notification(group_id, channel_id): @@ -53,7 +54,7 @@ def save_notification( last_error=error, provider_status=provider_status, provider_payload=provider_payload, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) record.provider = provider or record.provider @@ -66,7 +67,7 @@ def save_notification( if provider_payload is not None: record.provider_payload = provider_payload - record.updated_at = datetime.utcnow() + record.updated_at = utc_now() record.save() return record @@ -86,9 +87,9 @@ def update_notification_callback_state( if provider_payload is not None: notification.provider_payload = provider_payload - notification.last_callback_at = datetime.utcnow() + notification.last_callback_at = utc_now() notification.callback_count = (notification.callback_count or 0) + 1 - notification.updated_at = datetime.utcnow() + notification.updated_at = utc_now() notification.save() return notification diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index c6c83d8..1daf8a6 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -20,6 +20,7 @@ issues_to_messages, validate_rule_definition, ) +from app.modules.common import utc_now VALID_SCOPES = {"global", "service"} @@ -88,7 +89,7 @@ def definition_hash(definition: Dict[str, Any]) -> str: def _utcnow() -> datetime: - return datetime.utcnow() + return utc_now() def _get_orchestration(orchestration_id: int) -> EventOrchestration: diff --git a/app/modules/db/priority_policies_repo.py b/app/modules/db/priority_policies_repo.py index 1071729..cedb851 100644 --- a/app/modules/db/priority_policies_repo.py +++ b/app/modules/db/priority_policies_repo.py @@ -9,6 +9,7 @@ PriorityPolicyRule, Service, ) +from app.modules.common import utc_now def list_priority_policies( @@ -129,8 +130,8 @@ def create_priority_policy( source_priority_mode=source_priority_mode, fallback_mode=fallback_mode, fallback_priority=fallback_priority_id, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -159,7 +160,7 @@ def restore_priority_policy( policy.fallback_priority = fallback_priority_id policy.deleted = False policy.deleted_at = None - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -184,7 +185,7 @@ def update_priority_policy(policy_id, data): if field in allowed_fields: setattr(policy, field, value) - policy.updated_at = datetime.utcnow() + policy.updated_at = utc_now() policy.save() return policy @@ -194,7 +195,7 @@ def clear_default_priority_policies(team_id, *, exclude_policy_id=None): """Clear the default flag from policies owned by a team.""" query = PriorityPolicy.update( default_for_team=False, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ).where( PriorityPolicy.team == team_id, PriorityPolicy.default_for_team == True, @@ -210,7 +211,7 @@ def clear_default_priority_policies(team_id, *, exclude_policy_id=None): def soft_delete_priority_policy(policy_id): """Soft-delete a policy and all of its rules.""" policy = get_priority_policy(policy_id) - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): rules = list_priority_policy_rules(policy.id) @@ -369,8 +370,8 @@ def create_priority_policy_rule( matcher_preset=matcher_preset_id, priority=priority_id, enabled=enabled, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -392,7 +393,7 @@ def update_priority_policy_rule(rule_id, data): if field in allowed_fields: setattr(rule, field, value) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -401,7 +402,7 @@ def update_priority_policy_rule(rule_id, data): def soft_delete_priority_policy_rule(rule_id): """Soft-delete one priority policy rule.""" rule = get_priority_policy_rule(rule_id) - now = datetime.utcnow() + now = utc_now() rule.enabled = False rule.deleted = True @@ -427,7 +428,7 @@ def reorder_priority_policy_rules(policy_id, ordered_rule_ids): ( PriorityPolicyRule.update( position=temporary_position + index, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( PriorityPolicyRule.id == rule_id, @@ -441,7 +442,7 @@ def reorder_priority_policy_rules(policy_id, ordered_rule_ids): ( PriorityPolicyRule.update( position=position, - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( PriorityPolicyRule.id == rule_id, diff --git a/app/modules/db/rotations_repo.py b/app/modules/db/rotations_repo.py index 2a66c4c..702c99d 100644 --- a/app/modules/db/rotations_repo.py +++ b/app/modules/db/rotations_repo.py @@ -16,6 +16,7 @@ TeamUser, User ) +from app.modules.common import utc_now def _rotation_member_period_filter(model, at): @@ -222,7 +223,7 @@ def list_rotation_overrides(rotation_id, start_at=None, end_at=None, include_exp & (RotationOverride.ends_at > start_at) ) elif not include_expired: - query = query.where(RotationOverride.ends_at > datetime.utcnow()) + query = query.where(RotationOverride.ends_at > utc_now()) return list(query.order_by(RotationOverride.starts_at.asc(), RotationOverride.id.asc())) @@ -232,7 +233,7 @@ def get_active_override(rotation_id, now=None): Return the active override for a rotation. """ - now = now or datetime.utcnow() + now = now or utc_now() return ( RotationOverride.select() .where( @@ -315,7 +316,7 @@ def soft_delete_rotation(rotation_id): RotationLayer.update( enabled=False, deleted=True, - deleted_at=datetime.utcnow(), + deleted_at=utc_now(), ).where( RotationLayer.id.in_(layer_ids) ).execute() @@ -339,7 +340,7 @@ def soft_delete_rotation(rotation_id): rotation.enabled = False rotation.deleted = True - rotation.deleted_at = datetime.utcnow() + rotation.deleted_at = utc_now() rotation.save() return rotation @@ -578,7 +579,7 @@ def soft_delete_rotation_layer(layer_id): layer = get_rotation_layer(layer_id) layer.enabled = False layer.deleted = True - layer.deleted_at = datetime.utcnow() + layer.deleted_at = utc_now() layer.save() return layer @@ -675,7 +676,7 @@ def list_rotation_layer_member_periods( def add_rotation_layer_member(layer_id, user_id, position, starts_at=None): """Add user to layer as a new membership period.""" - starts_at = starts_at or datetime.utcnow() + starts_at = starts_at or utc_now() with database_proxy.atomic(): ( @@ -730,7 +731,7 @@ def update_rotation_layer_member(member_id, position, active=True): """Update layer member without rewriting historical schedule.""" member = get_rotation_layer_member(member_id) - now = datetime.utcnow() + now = utc_now() if not active: member.active = False @@ -777,7 +778,7 @@ def delete_rotation_layer_member(member_id): member.active = False if member.ends_at is None: - member.ends_at = datetime.utcnow() + member.ends_at = utc_now() member.save() return data diff --git a/app/modules/db/routes_repo.py b/app/modules/db/routes_repo.py index 9ff4456..1a7c437 100644 --- a/app/modules/db/routes_repo.py +++ b/app/modules/db/routes_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import AlertRoute, AlertRouteChannel, Group, Team +from app.modules.common import utc_now def list_routes(team_id=None, team_ids=None, enabled_only=False, source=None, active_only=True, include_deleted=False): @@ -309,7 +310,7 @@ def soft_delete_route(route_id): route.enabled = False route.deleted = True - route.deleted_at = datetime.utcnow() + route.deleted_at = utc_now() route.save() AlertRouteChannel.delete().where( diff --git a/app/modules/db/services_repo.py b/app/modules/db/services_repo.py index 156dc4e..8f65ba5 100644 --- a/app/modules/db/services_repo.py +++ b/app/modules/db/services_repo.py @@ -13,6 +13,7 @@ ServiceSloMeasurement, User, ) +from app.modules.common import utc_now def list_services(team_id=None, team_ids=None, include_disabled=True): @@ -88,7 +89,7 @@ def restore_service(service_id, data): service.deleted = False service.deleted_at = None - service.updated_at = datetime.utcnow() + service.updated_at = utc_now() service.save() return service @@ -136,7 +137,7 @@ def create_service(data): if existing and existing.deleted: return restore_service(existing.id, data) - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return Service.create(**data) @@ -152,7 +153,7 @@ def update_service(service_id, data): for field, value in data.items(): setattr(service, field, value) - service.updated_at = datetime.utcnow() + service.updated_at = utc_now() service.save() return service @@ -161,7 +162,7 @@ def update_service(service_id, data): def soft_delete_service(service_id): """Soft-delete a service and service-owned routing helpers.""" service = get_service(service_id) - now = datetime.utcnow() + now = utc_now() service.enabled = False service.deleted = True @@ -270,7 +271,7 @@ def get_match_rule(rule_id): def create_match_rule(data): """Create a service match rule.""" - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceMatchRule.create(**data) @@ -281,7 +282,7 @@ def update_match_rule(rule_id, data): for field, value in data.items(): setattr(rule, field, value) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -292,8 +293,8 @@ def soft_delete_match_rule(rule_id): rule = get_match_rule(rule_id) rule.enabled = False rule.deleted = True - rule.deleted_at = datetime.utcnow() - rule.updated_at = datetime.utcnow() + rule.deleted_at = utc_now() + rule.updated_at = utc_now() rule.save() return rule @@ -486,7 +487,7 @@ def get_service_link(link_id): def create_service_link(service_id, data): """Create a service link.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceLink.create(**data) @@ -497,7 +498,7 @@ def update_service_link(link_id, data): for field, value in data.items(): setattr(link, field, value) - link.updated_at = datetime.utcnow() + link.updated_at = utc_now() link.save() return link @@ -508,8 +509,8 @@ def soft_delete_service_link(link_id): link = get_service_link(link_id) link.enabled = False link.deleted = True - link.deleted_at = datetime.utcnow() - link.updated_at = datetime.utcnow() + link.deleted_at = utc_now() + link.updated_at = utc_now() link.save() return link @@ -531,7 +532,7 @@ def get_service_runbook(runbook_id): def create_service_runbook(service_id, data): """Create a service runbook.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceRunbook.create(**data) @@ -542,7 +543,7 @@ def update_service_runbook(runbook_id, data): for field, value in data.items(): setattr(runbook, field, value) - runbook.updated_at = datetime.utcnow() + runbook.updated_at = utc_now() runbook.save() return runbook @@ -553,8 +554,8 @@ def soft_delete_service_runbook(runbook_id): runbook = get_service_runbook(runbook_id) runbook.enabled = False runbook.deleted = True - runbook.deleted_at = datetime.utcnow() - runbook.updated_at = datetime.utcnow() + runbook.deleted_at = utc_now() + runbook.updated_at = utc_now() runbook.save() return runbook @@ -575,7 +576,7 @@ def get_service_dependency(dependency_id): def create_service_dependency(service_id, data): """Create a service dependency.""" data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceDependency.create(**data) @@ -586,7 +587,7 @@ def update_service_dependency(dependency_id, data): for field, value in data.items(): setattr(dependency, field, value) - dependency.updated_at = datetime.utcnow() + dependency.updated_at = utc_now() dependency.save() return dependency @@ -597,8 +598,8 @@ def soft_delete_service_dependency(dependency_id): dependency = get_service_dependency(dependency_id) dependency.enabled = False dependency.deleted = True - dependency.deleted_at = datetime.utcnow() - dependency.updated_at = datetime.utcnow() + dependency.deleted_at = utc_now() + dependency.updated_at = utc_now() dependency.save() return dependency @@ -783,7 +784,7 @@ def create_service_sli(service_id, data): """Create a Service Level Indicator.""" data = dict(data) data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceSli.create(**data) @@ -794,7 +795,7 @@ def update_service_sli(sli_id, data): for field, value in data.items(): setattr(sli, field, value) - sli.updated_at = datetime.utcnow() + sli.updated_at = utc_now() sli.save() return sli @@ -805,8 +806,8 @@ def soft_delete_service_sli(sli_id): sli = get_service_sli(sli_id) sli.enabled = False sli.deleted = True - sli.deleted_at = datetime.utcnow() - sli.updated_at = datetime.utcnow() + sli.deleted_at = utc_now() + sli.updated_at = utc_now() sli.save() return sli @@ -856,7 +857,7 @@ def create_service_slo(service_id, data): """Create a Service Level Objective.""" data = dict(data) data["service"] = service_id - data["updated_at"] = datetime.utcnow() + data["updated_at"] = utc_now() return ServiceSlo.create(**data) @@ -867,7 +868,7 @@ def update_service_slo(slo_id, data): for field, value in data.items(): setattr(slo, field, value) - slo.updated_at = datetime.utcnow() + slo.updated_at = utc_now() slo.save() return slo @@ -878,8 +879,8 @@ def soft_delete_service_slo(slo_id): slo = get_service_slo(slo_id) slo.enabled = False slo.deleted = True - slo.deleted_at = datetime.utcnow() - slo.updated_at = datetime.utcnow() + slo.deleted_at = utc_now() + slo.updated_at = utc_now() slo.save() return slo diff --git a/app/modules/db/silences_repo.py b/app/modules/db/silences_repo.py index b91c975..50c5df6 100644 --- a/app/modules/db/silences_repo.py +++ b/app/modules/db/silences_repo.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from app.modules.db.models import Group, Silence, Team +from app.modules.common import utc_now def list_silences( @@ -40,7 +41,7 @@ def list_silences( ) if not include_expired_history: - cutoff = (now or datetime.utcnow()) - timedelta(days=expired_retention_days) + cutoff = (now or utc_now()) - timedelta(days=expired_retention_days) query = query.where(Silence.ends_at >= cutoff) if team_id: @@ -58,7 +59,7 @@ def list_active_silences(team_id, now=None): Return active silences for a team. """ - now = now or datetime.utcnow() + now = now or utc_now() return list( Silence.select() .where( @@ -137,6 +138,6 @@ def soft_delete_silence(silence_id): silence = get_silence(silence_id) silence.enabled = False silence.deleted = True - silence.deleted_at = datetime.utcnow() + silence.deleted_at = utc_now() silence.save() return silence diff --git a/app/modules/db/sso_repo.py b/app/modules/db/sso_repo.py index 63df60d..b91621b 100644 --- a/app/modules/db/sso_repo.py +++ b/app/modules/db/sso_repo.py @@ -3,6 +3,7 @@ from app.modules.db.models import Group, SsoGroupMapping, SsoIdentity, SsoProvider, Team from app.modules.sso.crypto import encrypt_secret from app.modules.sso.saml_security import normalize_sso_extra_config +from app.modules.common import utc_now PROVIDER_FIELDS = [ @@ -101,7 +102,7 @@ def _apply_provider_data(provider, data, update_secret=True): if private_key is not None: provider.saml_sp_private_key_encrypted = encrypt_secret(private_key) - provider.updated_at = datetime.utcnow() + provider.updated_at = utc_now() return provider @@ -162,7 +163,7 @@ def update_provider(provider_id: int, data: dict) -> SsoProvider: def soft_delete_provider(provider_id): """Soft-delete SSO provider and disable it.""" provider = get_provider(provider_id) - now = datetime.utcnow() + now = utc_now() database = SsoProvider._meta.database with database.atomic(): @@ -240,7 +241,7 @@ def update_group_mapping(mapping_id, data): mapping.team_role = data.get("team_role") if team else None mapping.active = data.get("active", True) mapping.priority = data.get("priority", 100) - mapping.updated_at = datetime.utcnow() + mapping.updated_at = utc_now() mapping.save() return mapping @@ -281,7 +282,7 @@ def create_identity(provider_id, user_id, subject, email=None, username=None, ra email=email, username=username, raw_claims=raw_claims, - last_login_at=datetime.utcnow(), + last_login_at=utc_now(), ) @@ -290,6 +291,6 @@ def touch_identity(identity, email=None, username=None, raw_claims=None): identity.email = email identity.username = username identity.raw_claims = raw_claims - identity.last_login_at = datetime.utcnow() + identity.last_login_at = utc_now() identity.save() return identity diff --git a/app/modules/db/teams_repo.py b/app/modules/db/teams_repo.py index cbafcdc..661417f 100644 --- a/app/modules/db/teams_repo.py +++ b/app/modules/db/teams_repo.py @@ -16,6 +16,7 @@ Team, TeamUser, ) +from app.modules.common import utc_now def list_teams(active_only=False, group_ids=None, include_deleted=False): @@ -177,7 +178,7 @@ def remove_team(team_id: int): def soft_delete_team(team_id: int): """Soft-delete a team and all resources under it.""" - now = datetime.utcnow() + now = utc_now() team = get_team(team_id) with Team._meta.database.atomic(): diff --git a/app/modules/db/tokens_repo.py b/app/modules/db/tokens_repo.py index 7c12991..04e867a 100644 --- a/app/modules/db/tokens_repo.py +++ b/app/modules/db/tokens_repo.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import ApiToken +from app.modules.common import utc_now def create_api_token(name, token_prefix, token_hash, scopes, team_id=None, group_id=None, user_id=None, expires_at=None): @@ -68,7 +69,7 @@ def revoke_user_token(token_id, user_id): token.active = False token.deleted = True - token.deleted_at = datetime.utcnow() + token.deleted_at = utc_now() token.save() return token @@ -91,7 +92,7 @@ def mark_token_used(token): Update last token usage timestamp. """ - token.last_used_at = datetime.utcnow() + token.last_used_at = utc_now() token.save() return token @@ -104,7 +105,7 @@ def soft_delete_token(token_id): token = ApiToken.get_by_id(token_id) token.active = False token.deleted = True - token.deleted_at = datetime.utcnow() + token.deleted_at = utc_now() token.save() return token diff --git a/app/modules/db/users_repo.py b/app/modules/db/users_repo.py index 3310e83..06b4ca9 100644 --- a/app/modules/db/users_repo.py +++ b/app/modules/db/users_repo.py @@ -14,6 +14,7 @@ UserGroup, UserRole, ) +from app.modules.common import utc_now DEFAULT_USERS_PAGE_SIZE = 25 @@ -283,7 +284,7 @@ def soft_delete_user(user_id): if not user: return None - now = datetime.utcnow() + now = utc_now() database = User._meta.database if user.is_admin and user.active and count_active_admins(exclude_user_id=user.id) == 0: diff --git a/app/modules/sso/sso_login.py b/app/modules/sso/sso_login.py index 97458a1..7c346bd 100644 --- a/app/modules/sso/sso_login.py +++ b/app/modules/sso/sso_login.py @@ -11,6 +11,7 @@ from app.modules.db.models import SsoGroupMapping, SsoIdentity, User, UserGroup from app.settings import Config from app.api.schemas.limits import normalize_phone +from app.modules.common import utc_now class SsoLoginError(Exception): @@ -253,7 +254,7 @@ def _resolve_sso_user(provider, claims): identity.email = email identity.username = username identity.raw_claims = raw_claims - identity.last_login_at = datetime.utcnow() + identity.last_login_at = utc_now() identity.save() return user @@ -296,7 +297,7 @@ def _resolve_sso_user(provider, claims): email=email, username=username, raw_claims=raw_claims, - last_login_at=datetime.utcnow(), + last_login_at=utc_now(), ) except IntegrityError: identity = SsoIdentity.get( diff --git a/app/notifiers/browser_push/service.py b/app/notifiers/browser_push/service.py index 3a13a68..0462262 100644 --- a/app/notifiers/browser_push/service.py +++ b/app/notifiers/browser_push/service.py @@ -21,6 +21,7 @@ alert_priority_short_label, format_alert_title_with_priority, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.notifications") @@ -72,7 +73,7 @@ def save_user_subscription(user, endpoint, keys, device_name=None, user_agent=No BrowserPushSubscription.endpoint == endpoint ) - now = datetime.utcnow() + now = utc_now() if not subscription: return BrowserPushSubscription.create( @@ -115,8 +116,8 @@ def disable_user_subscription(user, subscription_id): subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() return subscription @@ -135,7 +136,7 @@ def create_action_token(user, group, action): group=group.id, action=action, token_hash=_hash_token(raw_token), - expires_at=datetime.utcnow() + timedelta(seconds=ttl), + expires_at=utc_now() + timedelta(seconds=ttl), ) return raw_token @@ -243,7 +244,7 @@ def send_alert_push_to_user(user, group, event_type="notification"): for subscription in subscriptions: try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -252,8 +253,8 @@ def send_alert_push_to_user(user, group, event_type="notification"): if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -410,7 +411,7 @@ def send_stakeholder_push_to_user( for subscription in subscriptions: try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -423,8 +424,8 @@ def send_stakeholder_push_to_user( if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -460,7 +461,7 @@ def send_test_push(user): for subscription in _active_user_subscriptions(user.id): try: _webpush(subscription, payload) - subscription.last_seen_at = datetime.utcnow() + subscription.last_seen_at = utc_now() subscription.save() sent += 1 except WebPushException as exc: @@ -469,8 +470,8 @@ def send_test_push(user): if status_code in {404, 410}: subscription.enabled = False subscription.deleted = True - subscription.deleted_at = datetime.utcnow() - subscription.updated_at = datetime.utcnow() + subscription.deleted_at = utc_now() + subscription.updated_at = utc_now() subscription.save() logger.warning( @@ -497,7 +498,7 @@ def execute_push_action(token, action): return {"ok": False, "error": "missing_token"} token_hash = _hash_token(token) - now = datetime.utcnow() + now = utc_now() record = BrowserPushActionToken.get_or_none( BrowserPushActionToken.token_hash == token_hash diff --git a/app/notifiers/telegram/poller.py b/app/notifiers/telegram/poller.py index 096ced3..44192d0 100644 --- a/app/notifiers/telegram/poller.py +++ b/app/notifiers/telegram/poller.py @@ -17,6 +17,7 @@ get_telegram_bot, update_telegram_alert, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.telegram") @@ -80,12 +81,12 @@ def _friendly_transport_error(exc): def _is_channel_transport_backoff_active(channel_id): retry_at = _channel_transport_failed_until.get(channel_id) - return bool(retry_at and retry_at > datetime.utcnow()) + return bool(retry_at and retry_at > utc_now()) def _mark_channel_transport_failed(channel_id): _channel_transport_failed_until[channel_id] = ( - datetime.utcnow() + timedelta(seconds=TELEGRAM_TRANSPORT_RETRY_SECONDS) + utc_now() + timedelta(seconds=TELEGRAM_TRANSPORT_RETRY_SECONDS) ) @@ -101,12 +102,12 @@ def _is_telegram_unauthorized(exc): def _is_channel_auth_backoff_active(channel_id): retry_at = _channel_auth_failed_until.get(channel_id) - return bool(retry_at and retry_at > datetime.utcnow()) + return bool(retry_at and retry_at > utc_now()) def _mark_channel_auth_failed(channel_id): _channel_auth_failed_until[channel_id] = ( - datetime.utcnow() + timedelta(seconds=TELEGRAM_AUTH_RETRY_SECONDS) + utc_now() + timedelta(seconds=TELEGRAM_AUTH_RETRY_SECONDS) ) @@ -328,7 +329,7 @@ def handle_telegram_callback(channel, callback): "action": action, "user_id": user.id, "telegram_user_id": telegram_user_id, - "processed_at": datetime.utcnow().isoformat(), + "processed_at": utc_now().isoformat(), } }, ) diff --git a/app/services/alerts/correlation.py b/app/services/alerts/correlation.py index 4b8c2b1..d63f56f 100644 --- a/app/services/alerts/correlation.py +++ b/app/services/alerts/correlation.py @@ -5,6 +5,7 @@ from app.db import database_proxy from app.modules.db.models import AlertGroup, AlertGroupCorrelation, ServiceDependency from app.modules.db import alerts_repo +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -53,7 +54,7 @@ def _seen_at(group): getattr(group, "last_seen_at", None) or getattr(group, "first_seen_at", None) or getattr(group, "created_at", None) - or datetime.utcnow() + or utc_now() ) @@ -428,7 +429,7 @@ def _deactivate_stale_correlations(group, now, keep_ids=None): def refresh_alert_group_correlations(group): """Recalculate and persist dependency-aware correlations for a group.""" - now = datetime.utcnow() + now = utc_now() with database_proxy.atomic(): if getattr(group, "status", None) not in ACTIVE_ALERT_GROUP_STATUSES: diff --git a/app/services/alerts/escalation.py b/app/services/alerts/escalation.py index f4b5846..ed280ff 100644 --- a/app/services/alerts/escalation.py +++ b/app/services/alerts/escalation.py @@ -5,6 +5,7 @@ from app.services import escalation_policies as escalation_policy_service from app.services.notifications.delivery import has_matching_notification_channel, notify_alert from app.services.oncall import get_current_oncall_user, get_next_rotation_user +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -36,7 +37,7 @@ def apply_initial_escalation_policy_assignment(policy, fallback_rotation): delay_seconds = escalation_policy_service.get_rule_delay_seconds(policy_rule) if delay_seconds: - next_escalation_at = datetime.utcnow() + timedelta(seconds=delay_seconds) + next_escalation_at = utc_now() + timedelta(seconds=delay_seconds) return policy_rule, rotation, assignee, next_escalation_at @@ -71,7 +72,7 @@ def maybe_escalate_alert(group): if not next_user or (group.assignee and next_user.id == group.assignee.id): return False - now = datetime.utcnow() + now = utc_now() group.assignee = next_user.id group.escalation_level = (group.escalation_level or 0) + 1 @@ -97,7 +98,7 @@ def maybe_escalate_alert_by_policy(group): if not group.escalation_policy_id: return False - now = datetime.utcnow() + now = utc_now() if not group.next_escalation_at: return False diff --git a/app/services/alerts/explain_cleanup.py b/app/services/alerts/explain_cleanup.py index 9bc9b08..29b3847 100644 --- a/app/services/alerts/explain_cleanup.py +++ b/app/services/alerts/explain_cleanup.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta from app.modules.db import alerts_repo +from app.modules.common import utc_now DEFAULT_ALERT_EXPLAIN_RETENTION_DAYS = 30 @@ -16,7 +17,7 @@ def cleanup_alert_explain_traces(*, retention_days=None, now=None): raise ValueError("retention_days must be greater than 0") if now is None: - now = datetime.utcnow() + now = utc_now() cutoff = now - timedelta(days=retention_days) diff --git a/app/services/alerts/lifecycle.py b/app/services/alerts/lifecycle.py index 3837570..9060acb 100644 --- a/app/services/alerts/lifecycle.py +++ b/app/services/alerts/lifecycle.py @@ -34,6 +34,7 @@ from app.services.alerts.correlation import refresh_alert_group_correlations, refresh_alert_group_correlations_safely from app.services.business_services.impact import refresh_business_impacts_safely_for_group from app.services.business_services.status import refresh_business_services_safely_for_technical_service +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -466,7 +467,7 @@ def _upsert_alert(alert_data, trace): trace.group_key_built(group_key) - now = datetime.utcnow() + now = utc_now() maintenance_decision = get_maintenance_decision( team=team, diff --git a/app/services/alerts/notification_queue.py b/app/services/alerts/notification_queue.py index 9459132..06c5d28 100644 --- a/app/services/alerts/notification_queue.py +++ b/app/services/alerts/notification_queue.py @@ -4,6 +4,7 @@ from app import Config from app.modules.db import alerts_repo from app.services.notifications.delivery import notify_alert +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -19,7 +20,7 @@ def _alert_group_interval_seconds(): def schedule_group_notification(group, reason="notification", now=None): """Schedule group notification according to group_wait/group_interval.""" - now = now or datetime.utcnow() + now = now or utc_now() if group.status != "firing": alerts_repo.clear_alert_group_notification(group) @@ -45,7 +46,7 @@ def schedule_group_notification(group, reason="notification", now=None): def process_due_alert_group_notifications(limit=100): """Send due alert group notifications.""" - now = datetime.utcnow() + now = utc_now() sent = 0 skipped = 0 failed = 0 diff --git a/app/services/alerts/priority.py b/app/services/alerts/priority.py index 92ea4e4..9d96171 100644 --- a/app/services/alerts/priority.py +++ b/app/services/alerts/priority.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db import incidents_repo, alerts_repo +from app.modules.common import utc_now PRIORITY_DISPLAY_LABELS = { "p1": "P1 Critical", @@ -147,7 +148,7 @@ def _save_auto_priority(group, priority, *, event_type, message): group.priority_slug = priority.slug group.priority_order = priority.level group.priority_set_manually = False - group.updated_at = datetime.utcnow() + group.updated_at = utc_now() group.save(only=[ group.__class__.priority, diff --git a/app/services/alerts/reminders.py b/app/services/alerts/reminders.py index 2bb8f2c..c52909f 100644 --- a/app/services/alerts/reminders.py +++ b/app/services/alerts/reminders.py @@ -5,6 +5,7 @@ from app.services import escalation_policies as escalation_policy_service from app.services.alerts.escalation import maybe_escalate_alert from app.services.notifications.delivery import has_matching_notification_channel, notify_alert +from app.modules.common import utc_now logger = logging.getLogger("oncall.alerts") @@ -42,7 +43,7 @@ def send_unacked_reminders(): policy escalation must work even when reminder interval is disabled. """ - now = datetime.utcnow() + now = utc_now() count = 0 for group in alerts_repo.list_firing_alert_groups(): diff --git a/app/services/business_services/status.py b/app/services/business_services/status.py index 6a72423..accbfcb 100644 --- a/app/services/business_services/status.py +++ b/app/services/business_services/status.py @@ -11,6 +11,7 @@ combined_impact_score, status_from_impact_score, ) +from app.modules.common import utc_now logger = logging.getLogger("oncall.business_services") @@ -50,7 +51,7 @@ def is_business_service_manual_status_active(business_service, now=None): if until is None: return True - now = now or datetime.utcnow() + now = now or utc_now() return until > now @@ -64,7 +65,7 @@ def clear_expired_business_service_manual_status(business_service, now=None): if until is None: return False - now = now or datetime.utcnow() + now = now or utc_now() if until > now: return False @@ -331,7 +332,7 @@ def apply_business_service_status(business_service): or old_message != new_message ) - now = datetime.utcnow() + now = utc_now() business_service.status = new_status business_service.status_source = new_source diff --git a/app/services/caldav/auth.py b/app/services/caldav/auth.py index ce88ba9..3c6146b 100644 --- a/app/services/caldav/auth.py +++ b/app/services/caldav/auth.py @@ -7,6 +7,7 @@ from app.modules.db import tokens_repo from app.modules.db.models import User from app.services.integrations.auth import hash_token, token_has_scope +from app.modules.common import utc_now CALDAV_REQUIRED_SCOPES = ["calendar:read"] @@ -63,7 +64,7 @@ def authenticate_caldav_user(username, token): if not api_token: return None - if api_token.expires_at and api_token.expires_at <= datetime.utcnow(): + if api_token.expires_at and api_token.expires_at <= utc_now(): return None if not api_token.user or api_token.user.id != user.id: diff --git a/app/services/calendar_feeds.py b/app/services/calendar_feeds.py index c349ebc..187f88d 100644 --- a/app/services/calendar_feeds.py +++ b/app/services/calendar_feeds.py @@ -8,6 +8,7 @@ from app.modules.db import calendar_feeds_repo from app.services.calendar_service import build_team_calendar from app.services.caldav.ics import build_calendar_ics +from app.modules.common import utc_now CALENDAR_FEED_TOKEN_PREFIX_LENGTH = 12 @@ -83,7 +84,7 @@ def serialize_calendar_feed(feed, base_url=None, token=None): def build_ics_for_calendar_feed(feed): - now = datetime.utcnow() + now = utc_now() start_at = now - timedelta(days=int(feed.past_days or 7)) end_at = now + timedelta(days=int(feed.future_days or 90)) diff --git a/app/services/heartbeats/service.py b/app/services/heartbeats/service.py index 5bab01c..92c773f 100644 --- a/app/services/heartbeats/service.py +++ b/app/services/heartbeats/service.py @@ -9,6 +9,7 @@ from app.services.alerts.actions import resolve_alert from app.services.alerts.lifecycle import upsert_alert from app.services.integrations.auth import create_raw_token, hash_token +from app.modules.common import utc_now logger = logging.getLogger("oncall.heartbeats") @@ -23,7 +24,7 @@ def _utcnow(): - return datetime.utcnow().replace(microsecond=0) + return utc_now().replace(microsecond=0) def _as_naive_utc(value): diff --git a/app/services/incidents/manual.py b/app/services/incidents/manual.py index 9f3f4ae..4d6f47a 100644 --- a/app/services/incidents/manual.py +++ b/app/services/incidents/manual.py @@ -9,6 +9,7 @@ from app.services.alerts.priority import incident_priority_create_kwargs, incident_priority_from_alert from app.services.incidents.stakeholders import notify_stakeholders from app.services.routing.service_resolution import get_effective_escalation_policy, get_effective_route_rotation +from app.modules.common import utc_now def _get_manual_incident_team(team_id): @@ -68,7 +69,7 @@ def create_manual_incident(payload, *, user_id=None): manual_id = uuid4().hex group_key = f"manual:{manual_id}" - now = datetime.utcnow() + now = utc_now() alert_data = { "source": "manual", diff --git a/app/services/integrations/auth.py b/app/services/integrations/auth.py index 42712a6..4e90c38 100644 --- a/app/services/integrations/auth.py +++ b/app/services/integrations/auth.py @@ -8,6 +8,7 @@ from app.middleware import load_jwt_user from app.modules.db import routes_repo, tokens_repo from app.settings import Config +from app.modules.common import utc_now def create_raw_token(): @@ -73,7 +74,7 @@ def authenticate_api_token(raw_token=None): request.current_api_token = None return None - if api_token.expires_at and api_token.expires_at <= datetime.utcnow(): + if api_token.expires_at and api_token.expires_at <= utc_now(): request.current_api_token = None return None diff --git a/app/services/notifications/delivery.py b/app/services/notifications/delivery.py index e298cfd..1517478 100644 --- a/app/services/notifications/delivery.py +++ b/app/services/notifications/delivery.py @@ -10,6 +10,7 @@ from app.services.alerts.priority import alert_priority_label, format_alert_title_with_priority from app.services.notifications.policies.resolver import resolve_notification_channels from app.services.alerts.correlation import format_correlation_plain +from app.modules.common import utc_now EDITABLE_EVENTS = {"acknowledged", "resolved"} @@ -378,7 +379,7 @@ def notify_alert(group, event_type="notification"): ) if sent_count: - alerts_repo.record_group_notification_time(group, datetime.utcnow()) + alerts_repo.record_group_notification_time(group, utc_now()) return sent_count diff --git a/app/services/notifications/rules.py b/app/services/notifications/rules.py index 3c3f7a8..e2bc224 100644 --- a/app/services/notifications/rules.py +++ b/app/services/notifications/rules.py @@ -13,6 +13,7 @@ from app.notifiers.voice.notifier import VoiceCallNotifier from app.services.severity import normalize_severity, normalize_severity_list from app.modules.db import alerts_repo +from app.modules.common import utc_now logger = logging.getLogger("oncall.notification_rules") @@ -123,8 +124,8 @@ def create_user_rule( severities=severities or [], event_types=event_types or [], enabled=enabled, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -143,7 +144,7 @@ def update_user_rule(user, rule_id, payload): rule.severities = severities or [] rule.event_types = event_types or [] rule.enabled = bool(payload.get("enabled", rule.enabled)) - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -153,9 +154,9 @@ def delete_user_rule(user, rule_id): rule = get_user_rule(user, rule_id) rule.deleted = True - rule.deleted_at = datetime.utcnow() + rule.deleted_at = utc_now() rule.enabled = False - rule.updated_at = datetime.utcnow() + rule.updated_at = utc_now() rule.save() return rule @@ -301,7 +302,7 @@ def enqueue_user_notifications(group, event_type="notification"): except User.DoesNotExist: return 0 - now = datetime.utcnow() + now = utc_now() # Default profile browser push when the user has no custom rules. if not has_custom_user_rules(assignee.id): @@ -354,14 +355,14 @@ def create_delivery(group, user, rule, method, event_type, scheduled_at): event_type=event_type, status="pending", scheduled_at=scheduled_at, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ) def process_due_user_notifications(limit=100): """Process due user notification deliveries and return sent count.""" - now = datetime.utcnow() + now = utc_now() processed = 0 due_deliveries = ( @@ -380,7 +381,7 @@ def process_due_user_notifications(limit=100): UserNotificationDelivery .update( status="processing", - updated_at=datetime.utcnow(), + updated_at=utc_now(), ) .where( (UserNotificationDelivery.id == due_delivery.id) @@ -408,7 +409,7 @@ def process_due_user_notifications(limit=100): def send_browser_push_delivery(delivery): """Send browser push delivery and update delivery history.""" - now = datetime.utcnow() + now = utc_now() group = delivery.group try: @@ -493,14 +494,14 @@ def send_delivery(delivery): return 0 delivery.status = "sent" - delivery.sent_at = datetime.utcnow() + delivery.sent_at = utc_now() delivery.provider = result.get("provider") or delivery.method delivery.external_message_id = result.get("external_message_id") delivery.external_channel_id = result.get("external_channel_id") delivery.provider_status = result.get("provider_status") delivery.provider_payload = result.get("provider_payload") delivery.last_error = None - delivery.updated_at = datetime.utcnow() + delivery.updated_at = utc_now() delivery.save() return 1 @@ -555,7 +556,7 @@ def build_voice_rule_callback_url(delivery): def mark_delivery_skipped(delivery, reason): """Mark user notification delivery as skipped.""" - now = datetime.utcnow() + now = utc_now() ( UserNotificationDelivery @@ -573,5 +574,5 @@ def mark_delivery_skipped(delivery, reason): def mark_delivery_failed(delivery, error): delivery.status = "failed" delivery.last_error = str(error) - delivery.updated_at = datetime.utcnow() + delivery.updated_at = utc_now() delivery.save() diff --git a/app/services/notifications/shift_notifications.py b/app/services/notifications/shift_notifications.py index 70c3d8a..d2a9ffa 100644 --- a/app/services/notifications/shift_notifications.py +++ b/app/services/notifications/shift_notifications.py @@ -14,6 +14,7 @@ from app.notifiers.registry import get_notifier from app.notifiers.types import MATTERMOST_CHANNEL from app.services.calendar_service import build_rotation_calendar +from app.modules.common import utc_now logger = logging.getLogger("oncall.shift_notifications") @@ -184,8 +185,8 @@ def _get_or_create_log(user, rotation, event, event_type): "layer_id": event.get("layer_id"), "override_id": event.get("override_id"), "status": "pending", - "created_at": datetime.utcnow(), - "updated_at": datetime.utcnow(), + "created_at": utc_now(), + "updated_at": utc_now(), }, ) @@ -195,10 +196,10 @@ def _get_or_create_log(user, rotation, event, event_type): def _mark_log(log, status, error=None): log.status = status log.last_error = error - log.updated_at = datetime.utcnow() + log.updated_at = utc_now() if status == "sent": - log.sent_at = datetime.utcnow() + log.sent_at = utc_now() log.save() @@ -223,8 +224,8 @@ def _get_or_create_mattermost_log(user, rotation, event, event_type): "override_id": event.get("override_id"), "mattermost_user_id": mattermost_user_id, "status": "pending", - "created_at": datetime.utcnow(), - "updated_at": datetime.utcnow(), + "created_at": utc_now(), + "updated_at": utc_now(), }, ) @@ -366,7 +367,7 @@ def send_due_oncall_shift_email_notifications(now=None, lookback_seconds=None): The scheduler runs periodically, so this job looks back a small window and uses OnCallShiftEmailNotification.fingerprint to avoid duplicates. """ - now = now or datetime.utcnow() + now = now or utc_now() if lookback_seconds is None: lookback_seconds = int( @@ -414,7 +415,7 @@ def send_due_oncall_shift_mattermost_notifications(now=None, lookback_seconds=No User.mattermost_user_id is set. End-of-shift messages are intentionally not sent to avoid noisy direct messages. """ - now = now or datetime.utcnow() + now = now or utc_now() if lookback_seconds is None: lookback_seconds = int( diff --git a/app/services/oncall.py b/app/services/oncall.py index f3cd47d..61856b0 100644 --- a/app/services/oncall.py +++ b/app/services/oncall.py @@ -2,6 +2,7 @@ from zoneinfo import ZoneInfo from app.modules.db import rotations_repo +from app.modules.common import utc_now def _effective_layer_value(layer, field_name): @@ -93,7 +94,7 @@ def is_layer_active_now(layer, now): def get_scheduled_oncall_user_for_layer(layer, now=None): """Return scheduled user for one layer.""" - now = _as_utc_naive(now or datetime.utcnow()) + now = _as_utc_naive(now or utc_now()) members = rotations_repo.list_rotation_layer_members( layer.id, @@ -126,7 +127,7 @@ def get_active_rotation_layer(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = _as_utc_naive(now or utc_now()) layers = rotations_repo.list_rotation_layers( rotation.id, @@ -148,7 +149,7 @@ def get_scheduled_oncall_user(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = _as_utc_naive(now or utc_now()) layer = get_active_rotation_layer(rotation, now) if not layer: @@ -165,7 +166,7 @@ def get_current_oncall_user(rotation, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = _as_utc_naive(now or utc_now()) override = rotations_repo.get_active_override(rotation.id, now) @@ -181,7 +182,7 @@ def get_next_rotation_user(rotation, current_user=None, now=None): if not rotation or not rotation.enabled or rotation.deleted: return None - now = _as_utc_naive(now or datetime.utcnow()) + now = _as_utc_naive(now or utc_now()) layer = get_active_rotation_layer(rotation, now) if not layer: diff --git a/app/services/oncall_health.py b/app/services/oncall_health.py index 1d4fd26..565f62e 100644 --- a/app/services/oncall_health.py +++ b/app/services/oncall_health.py @@ -6,6 +6,7 @@ from app.modules.db import channels_repo, rotations_repo, routes_repo, teams_repo from app.services.oncall import get_current_oncall_user +from app.modules.common import utc_now DEFAULT_HEALTH_WINDOW_DAYS = 7 DEFAULT_HEALTH_SAMPLE_MINUTES = 60 @@ -61,7 +62,7 @@ def to_dict(self) -> dict: def utc_now_naive() -> datetime: - return datetime.utcnow().replace(microsecond=0) + return utc_now().replace(microsecond=0) def as_utc_naive(value: datetime | None) -> datetime | None: diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py index 6b64a39..68050e9 100644 --- a/app/services/orchestration/__init__.py +++ b/app/services/orchestration/__init__.py @@ -22,3 +22,33 @@ "validate_extractors", "validate_template", ] + +# BEGIN EVENT ORCHESTRATION WS3 EXPORTS +from .actions import ( + ActionExecutionResult, + ActionStepResult, + ActionValidationError, + EventActionState, + execute_actions, + validate_action, + validate_action_list, +) +from .engine import ( + OrchestrationExecutionResult, + RuleExecutionResult, + execute_rule_tree, +) + +__all__ += [ + "ActionExecutionResult", + "ActionStepResult", + "ActionValidationError", + "EventActionState", + "OrchestrationExecutionResult", + "RuleExecutionResult", + "execute_actions", + "execute_rule_tree", + "validate_action", + "validate_action_list", +] +# END EVENT ORCHESTRATION WS3 EXPORTS diff --git a/app/services/orchestration/actions.py b/app/services/orchestration/actions.py new file mode 100644 index 0000000..b821046 --- /dev/null +++ b/app/services/orchestration/actions.py @@ -0,0 +1,719 @@ +"""Deterministic Event Orchestration action execution. + +The action layer mutates an isolated JSON-compatible event state. It never +loads database entities, performs network I/O or calls provider integrations. +Static entity references are validated by the control-plane repository before +publication and are resolved by a later ingestion-integration workstream. +""" + +from __future__ import annotations + +import copy +import json +import re +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, MutableMapping, Optional, Sequence, Tuple + +from .errors import OrchestrationEvaluationError, ValidationIssue +from .fields import MISSING, resolve_field +from .templates import render_template, validate_template +from .variables import EXTRACTION_TYPES, extract_variables, validate_extractor + + +MAX_ACTIONS_PER_RULE = 128 +MAX_ACTION_VALUE_DEPTH = 16 +MAX_ACTION_COLLECTION_ITEMS = 512 +MAX_ACTION_VALUE_BYTES = 65_536 +MAX_LABELS = 256 +MAX_LABEL_NAME_LENGTH = 128 +MAX_CUSTOM_FIELD_NAME_LENGTH = 128 +MAX_NOTE_LENGTH = 8_192 +MAX_PAUSE_SECONDS = 604_800 +MAX_GROUP_WINDOW_SECONDS = 86_400 + +FAILURE_MODES = frozenset({"continue", "stop_rule", "stop_orchestration"}) +PROCESS_DISPOSITIONS = frozenset({"process", "suppress", "pause", "drop"}) +EVENT_ACTIONS = frozenset({"trigger", "resolve"}) + +_TEXT_FIELD_ACTIONS = { + "set_title": "title", + "set_message": "message", + "set_description": "description", + "set_severity": "severity", + "set_priority": "priority", + "set_dedup_key": "dedup_key", + "set_group_key": "group_key", +} +_ROUTING_ACTIONS = { + "set_route": ("route", "route_id"), + "set_team": ("team", "team_id"), + "set_service": ("service", "service_id"), +} +_POLICY_ACTIONS = { + "set_escalation_policy": "escalation_policy_id", + "set_notification_policy": "notification_policy_id", + "set_priority_policy": "priority_policy_id", +} +SUPPORTED_ACTION_TYPES = frozenset( + set(EXTRACTION_TYPES) + | set(_TEXT_FIELD_ACTIONS) + | set(_ROUTING_ACTIONS) + | set(_POLICY_ACTIONS) + | { + "set_event_action", + "set_label", + "remove_label", + "set_custom_field", + "remove_custom_field", + "set_grouping", + "add_note", + "suppress", + "drop", + "pause", + } +) + +_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.:-]*$") + + +class ActionValidationError(OrchestrationEvaluationError): + code = "invalid_action" + + +@dataclass +class EventActionState: + """Mutable internal state; caller-owned mappings are always deep-copied.""" + + event: Dict[str, Any] + raw: Dict[str, Any] + variables: Dict[str, Any] + route: Dict[str, Any] + service: Dict[str, Any] + team: Dict[str, Any] + integration: Dict[str, Any] + time: Dict[str, Any] + result: Dict[str, Any] + + @classmethod + def from_context(cls, context: Mapping[str, Any]) -> "EventActionState": + event = _json_copy(context.get("event") or {}, path="event") + labels = context.get("labels") + if labels is None: + labels = event.get("labels") or {} + event["labels"] = _json_copy(labels, path="labels") + + result = _json_copy(context.get("result") or {}, path="result") + result.setdefault("disposition", "process") + result.setdefault("suppress_notifications", False) + result.setdefault("dropped", False) + result.setdefault("pause_seconds", None) + result.setdefault("routing", {}) + result.setdefault("policies", {}) + result.setdefault("grouping", {}) + result.setdefault("notes", []) + + if result["disposition"] not in PROCESS_DISPOSITIONS: + raise ActionValidationError("result.disposition is invalid", path="result.disposition") + if not isinstance(result["routing"], dict): + raise ActionValidationError("result.routing must be an object", path="result.routing") + if not isinstance(result["policies"], dict): + raise ActionValidationError("result.policies must be an object", path="result.policies") + if not isinstance(result["grouping"], dict): + raise ActionValidationError("result.grouping must be an object", path="result.grouping") + if not isinstance(result["notes"], list): + raise ActionValidationError("result.notes must be a list", path="result.notes") + + return cls( + event=event, + raw=_json_copy(context.get("raw") or {}, path="raw"), + variables=_json_copy(context.get("variables") or {}, path="variables"), + route=_json_copy(context.get("route") or {}, path="route"), + service=_json_copy(context.get("service") or {}, path="service"), + team=_json_copy(context.get("team") or {}, path="team"), + integration=_json_copy(context.get("integration") or {}, path="integration"), + time=_json_copy(context.get("time") or {}, path="time"), + result=result, + ) + + @property + def labels(self) -> Dict[str, Any]: + labels = self.event.setdefault("labels", {}) + if not isinstance(labels, dict): + raise ActionValidationError("event.labels must be an object", path="event.labels") + return labels + + def context(self) -> Dict[str, Any]: + return { + "event": self.event, + "labels": self.labels, + "raw": self.raw, + "variables": self.variables, + "route": self.route, + "service": self.service, + "team": self.team, + "integration": self.integration, + "time": self.time, + "result": self.result, + } + + def to_dict(self) -> Dict[str, Any]: + return _json_copy(self.context(), path="context") + + +@dataclass(frozen=True) +class ActionStepResult: + index: int + action_type: str + success: bool + code: str + reason: str + before: Any = None + after: Any = None + references: Tuple[str, ...] = field(default_factory=tuple) + failure_mode: str = "continue" + outcome: str = "continue" + + def to_dict(self) -> Dict[str, Any]: + return { + "index": self.index, + "type": self.action_type, + "success": self.success, + "code": self.code, + "reason": self.reason, + "before": _trace_value(self.before), + "after": _trace_value(self.after), + "references": list(self.references), + "failure_mode": self.failure_mode, + "outcome": self.outcome, + } + + +@dataclass(frozen=True) +class ActionExecutionResult: + state: EventActionState + steps: Tuple[ActionStepResult, ...] + outcome: str = "continue" + + @property + def context(self) -> Dict[str, Any]: + return self.state.to_dict() + + def to_dict(self) -> Dict[str, Any]: + return { + "context": self.context, + "steps": [step.to_dict() for step in self.steps], + "outcome": self.outcome, + } + + +def _trace_value(value: Any) -> Any: + if value is MISSING: + return None + if isinstance(value, str): + return value if len(value) <= 512 else value[:512] + "…" + if isinstance(value, list): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, tuple): + return [_trace_value(item) for item in value[:32]] + if isinstance(value, dict): + return {str(key): _trace_value(item) for key, item in list(value.items())[:32]} + if isinstance(value, (int, float, bool)) or value is None: + return value + return "" + + +def _validate_json_value(value: Any, *, path: str, depth: int = 0) -> None: + if depth > MAX_ACTION_VALUE_DEPTH: + raise ActionValidationError("action value exceeds the nesting limit", path=path) + if value is None or isinstance(value, (str, int, bool)): + return + if isinstance(value, float): + if value != value or value in (float("inf"), float("-inf")): + raise ActionValidationError("action value cannot contain non-finite numbers", path=path) + return + if isinstance(value, list): + if len(value) > MAX_ACTION_COLLECTION_ITEMS: + raise ActionValidationError("action value list exceeds the item limit", path=path) + for index, child in enumerate(value): + _validate_json_value(child, path=f"{path}[{index}]", depth=depth + 1) + return + if isinstance(value, dict): + if len(value) > MAX_ACTION_COLLECTION_ITEMS: + raise ActionValidationError("action value object exceeds the item limit", path=path) + for key, child in value.items(): + if not isinstance(key, str): + raise ActionValidationError("action object keys must be strings", path=path) + _validate_json_value(child, path=f"{path}.{key}", depth=depth + 1) + return + raise ActionValidationError("action value must be JSON-compatible", path=path) + + +def _json_copy(value: Any, *, path: str) -> Any: + _validate_json_value(value, path=path) + try: + encoded = json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")) + except (TypeError, ValueError) as exc: # defensive; validation above is explicit + raise ActionValidationError("action value must be JSON-compatible", path=path) from exc + if len(encoded.encode("utf-8")) > MAX_ACTION_VALUE_BYTES: + raise ActionValidationError("action value exceeds the size limit", path=path) + return copy.deepcopy(value) + + +def _action_value_sources(action: Mapping[str, Any]) -> List[str]: + return [key for key in ("value", "value_from", "template") if key in action] + + +def _resolve_value( + action: Mapping[str, Any], + state: EventActionState, + *, + path: str, + required: bool = True, +) -> Tuple[Any, Tuple[str, ...]]: + sources = _action_value_sources(action) + if not sources: + if required: + raise ActionValidationError("action requires value, value_from or template", path=path) + return None, () + if len(sources) != 1: + raise ActionValidationError("action value sources are mutually exclusive", path=path) + + source = sources[0] + if source == "value_from": + resolution = resolve_field(state.context(), action["value_from"]) + if not resolution.found: + raise ActionValidationError( + f"source field {resolution.normalized_reference!r} does not exist", + path=f"{path}.value_from", + ) + return _json_copy(resolution.value, path=f"{path}.value_from"), ( + resolution.normalized_reference, + ) + + value = action[source] + if source == "template" or ( + isinstance(value, str) and ("{{" in value or "}}" in value) + ): + if not isinstance(value, str): + raise ActionValidationError("template must be a string", path=f"{path}.{source}") + rendered = render_template(value, state.context()) + return rendered.value, rendered.references + return _json_copy(value, path=f"{path}.{source}"), () + + +def _scalar_text(value: Any, *, path: str, allow_empty: bool = True) -> str: + if isinstance(value, bool): + text = "true" if value else "false" + elif value is None: + text = "" + elif isinstance(value, (str, int, float)): + text = str(value) + else: + raise ActionValidationError("action value must be scalar", path=path) + if not allow_empty and not text.strip(): + raise ActionValidationError("action value cannot be empty", path=path) + return text + + +def _validate_name(value: Any, *, path: str, max_length: int) -> None: + if not isinstance(value, str) or not value: + raise ActionValidationError("name is required", path=path) + if len(value) > max_length: + raise ActionValidationError("name exceeds the size limit", path=path) + if not _NAME.fullmatch(value): + raise ActionValidationError( + "name must start with a letter or underscore and contain only letters, numbers, dot, colon, dash or underscore", + path=path, + ) + + +def _positive_id(value: Any, *, path: str) -> int: + if isinstance(value, bool): + raise ActionValidationError("reference id must be a positive integer", path=path) + try: + identifier = int(value) + except (TypeError, ValueError) as exc: + raise ActionValidationError("reference id must be a positive integer", path=path) from exc + if identifier <= 0: + raise ActionValidationError("reference id must be a positive integer", path=path) + return identifier + + +def _static_reference(action: Mapping[str, Any], keys: Sequence[str], *, path: str) -> int: + present = [key for key in keys if key in action and action[key] not in (None, "")] + params = action.get("params") + if isinstance(params, dict): + present.extend( + f"params.{key}" for key in keys if key in params and params[key] not in (None, "") + ) + if len(present) != 1: + raise ActionValidationError("action requires exactly one static reference id", path=path) + source = present[0] + if source.startswith("params."): + value = params[source.split(".", 1)[1]] + else: + value = action[source] + return _positive_id(value, path=f"{path}.{source}") + + +def _validate_value_source(action: Mapping[str, Any], *, path: str, required: bool = True) -> List[ValidationIssue]: + issues: List[ValidationIssue] = [] + sources = _action_value_sources(action) + if required and not sources: + issues.append(ValidationIssue(path, "missing_action_value", "action requires value, value_from or template")) + if len(sources) > 1: + issues.append(ValidationIssue(path, "conflicting_action_value", "action value sources are mutually exclusive")) + if "value_from" in action: + try: + resolve_field({root: {} for root in ("event", "labels", "raw", "variables", "route", "service", "team", "integration", "time", "result")}, action["value_from"]) + except ValueError as exc: + issues.append(ValidationIssue(f"{path}.value_from", "invalid_field_reference", str(exc))) + for source in ("value", "template"): + value = action.get(source) + if isinstance(value, str) and (source == "template" or "{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.{source}")) + return issues + + +def validate_action(action: Any, *, path: str = "action") -> List[ValidationIssue]: + if not isinstance(action, dict): + return [ValidationIssue(path, "invalid_action", "action must be an object")] + action_type = action.get("type") + if action_type not in SUPPORTED_ACTION_TYPES: + return [ + ValidationIssue( + f"{path}.type", + "unsupported_action", + f"unsupported orchestration action {action_type!r}", + ) + ] + if action_type in EXTRACTION_TYPES: + return validate_extractor(action, path=path) + + issues: List[ValidationIssue] = [] + failure_mode = action.get("on_failure", "continue") + if failure_mode not in FAILURE_MODES: + issues.append( + ValidationIssue( + f"{path}.on_failure", + "invalid_failure_mode", + "on_failure must be continue, stop_rule or stop_orchestration", + ) + ) + + if action_type in _TEXT_FIELD_ACTIONS or action_type in {"set_event_action", "set_label", "set_custom_field", "add_note"}: + issues.extend(_validate_value_source(action, path=path)) + + if action_type == "set_event_action" and "value" in action: + if action["value"] not in EVENT_ACTIONS: + issues.append( + ValidationIssue(f"{path}.value", "invalid_event_action", "event action must be trigger or resolve") + ) + elif action_type in {"set_label", "remove_label"}: + try: + _validate_name(action.get("name", action.get("label")), path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or f"{path}.name", exc.code, str(exc))) + elif action_type in {"set_custom_field", "remove_custom_field"}: + try: + _validate_name(action.get("name", action.get("field")), path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or f"{path}.name", exc.code, str(exc))) + elif action_type in _ROUTING_ACTIONS: + _, id_key = _ROUTING_ACTIONS[action_type] + try: + _static_reference(action, (id_key, "value"), path=path) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or path, exc.code, str(exc))) + elif action_type in _POLICY_ACTIONS: + id_key = _POLICY_ACTIONS[action_type] + try: + _static_reference(action, (id_key, "policy_id", "value"), path=path) + except ActionValidationError as exc: + issues.append(ValidationIssue(exc.path or path, exc.code, str(exc))) + elif action_type == "set_grouping": + keys = {"dedup_key", "group_key", "window_seconds", "strategy"} + if not any(key in action for key in keys): + issues.append(ValidationIssue(path, "empty_grouping_action", "set_grouping requires at least one grouping field")) + if "window_seconds" in action: + value = action["window_seconds"] + if isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= MAX_GROUP_WINDOW_SECONDS: + issues.append(ValidationIssue(f"{path}.window_seconds", "invalid_group_window", "grouping window must be between 0 and 86400 seconds")) + for key in ("dedup_key", "group_key", "strategy"): + value = action.get(key) + if isinstance(value, str) and ("{{" in value or "}}" in value): + issues.extend(validate_template(value, path=f"{path}.{key}")) + elif action_type == "pause": + seconds = action.get("seconds") + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: + issues.append(ValidationIssue(f"{path}.seconds", "invalid_pause_duration", "pause duration must be between 1 and 604800 seconds")) + + return issues + + +def validate_action_list(actions: Any, *, path: str = "actions") -> List[ValidationIssue]: + if not isinstance(actions, list): + return [ValidationIssue(path, "invalid_actions", "actions must be a list")] + if len(actions) > MAX_ACTIONS_PER_RULE: + return [ValidationIssue(path, "action_limit", "rule has too many actions")] + issues: List[ValidationIssue] = [] + for index, action in enumerate(actions): + issues.extend(validate_action(action, path=f"{path}[{index}]")) + return issues + + +def _execute_grouping(action: Mapping[str, Any], state: EventActionState, *, path: str) -> Tuple[Any, Any, Tuple[str, ...]]: + before = copy.deepcopy(state.result.get("grouping") or {}) + grouping: MutableMapping[str, Any] = state.result.setdefault("grouping", {}) + references: List[str] = [] + + for key in ("dedup_key", "group_key", "strategy"): + if key not in action: + continue + value = action[key] + if isinstance(value, str) and ("{{" in value or "}}" in value): + rendered = render_template(value, state.context()) + value = rendered.value + references.extend(rendered.references) + value = _scalar_text(value, path=f"{path}.{key}", allow_empty=False) + grouping[key] = value + if key in {"dedup_key", "group_key"}: + state.event[key] = value + + if "window_seconds" in action: + seconds = action["window_seconds"] + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 0 <= seconds <= MAX_GROUP_WINDOW_SECONDS: + raise ActionValidationError("grouping window must be between 0 and 86400 seconds", path=f"{path}.window_seconds") + grouping["window_seconds"] = seconds + + return before, copy.deepcopy(grouping), tuple(references) + + +def _execute_action(action: Mapping[str, Any], state: EventActionState, *, path: str) -> Tuple[Any, Any, Tuple[str, ...], str]: + action_type = action["type"] + + if action_type in _TEXT_FIELD_ACTIONS: + target = _TEXT_FIELD_ACTIONS[action_type] + before = state.event.get(target) + value, references = _resolve_value(action, state, path=path) + state.event[target] = _scalar_text(value, path=f"{path}.value") + return before, state.event[target], references, "continue" + + if action_type == "set_event_action": + before = state.event.get("event_action") + value, references = _resolve_value(action, state, path=path) + normalized = _scalar_text(value, path=f"{path}.value", allow_empty=False).lower() + if normalized not in EVENT_ACTIONS: + raise ActionValidationError("event action must be trigger or resolve", path=f"{path}.value") + state.event["event_action"] = normalized + return before, normalized, references, "continue" + + if action_type == "set_label": + name = action.get("name", action.get("label")) + _validate_name(name, path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + labels = state.labels + if name not in labels and len(labels) >= MAX_LABELS: + raise ActionValidationError("event label count exceeds the limit", path=f"{path}.name") + before = labels.get(name, MISSING) + value, references = _resolve_value(action, state, path=path) + labels[name] = _scalar_text(value, path=f"{path}.value") + return before, labels[name], references, "continue" + + if action_type == "remove_label": + name = action.get("name", action.get("label")) + _validate_name(name, path=f"{path}.name", max_length=MAX_LABEL_NAME_LENGTH) + before = state.labels.get(name, MISSING) + state.labels.pop(name, None) + return before, MISSING, (), "continue" + + if action_type == "set_custom_field": + name = action.get("name", action.get("field")) + _validate_name(name, path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + details = state.event.setdefault("custom_details", {}) + if not isinstance(details, dict): + raise ActionValidationError("event.custom_details must be an object", path="event.custom_details") + before = details.get(name, MISSING) + value, references = _resolve_value(action, state, path=path) + details[name] = _json_copy(value, path=f"{path}.value") + return before, details[name], references, "continue" + + if action_type == "remove_custom_field": + name = action.get("name", action.get("field")) + _validate_name(name, path=f"{path}.name", max_length=MAX_CUSTOM_FIELD_NAME_LENGTH) + details = state.event.setdefault("custom_details", {}) + if not isinstance(details, dict): + raise ActionValidationError("event.custom_details must be an object", path="event.custom_details") + before = details.get(name, MISSING) + details.pop(name, None) + return before, MISSING, (), "continue" + + if action_type in _ROUTING_ACTIONS: + root, id_key = _ROUTING_ACTIONS[action_type] + identifier = _static_reference(action, (id_key, "value"), path=path) + target = getattr(state, root) + before = copy.deepcopy(target) + target.clear() + target["id"] = identifier + state.result.setdefault("routing", {})[id_key] = identifier + return before, copy.deepcopy(target), (), "continue" + + if action_type in _POLICY_ACTIONS: + id_key = _POLICY_ACTIONS[action_type] + identifier = _static_reference(action, (id_key, "policy_id", "value"), path=path) + policies = state.result.setdefault("policies", {}) + before = policies.get(id_key, MISSING) + policies[id_key] = identifier + return before, identifier, (), "continue" + + if action_type == "set_grouping": + before, after, references = _execute_grouping(action, state, path=path) + return before, after, references, "continue" + + if action_type == "add_note": + before = list(state.result.setdefault("notes", [])) + value, references = _resolve_value(action, state, path=path) + note = _scalar_text(value, path=f"{path}.value", allow_empty=False) + if len(note) > MAX_NOTE_LENGTH: + raise ActionValidationError("note exceeds the size limit", path=f"{path}.value") + state.result["notes"].append(note) + return before, list(state.result["notes"]), references, "continue" + + if action_type == "suppress": + before = { + "disposition": state.result.get("disposition"), + "suppress_notifications": state.result.get("suppress_notifications"), + } + state.result["disposition"] = "suppress" + state.result["suppress_notifications"] = True + return before, {"disposition": "suppress", "suppress_notifications": True}, (), "continue" + + if action_type == "pause": + seconds = action.get("seconds") + if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: + raise ActionValidationError("pause duration must be between 1 and 604800 seconds", path=f"{path}.seconds") + before = { + "disposition": state.result.get("disposition"), + "pause_seconds": state.result.get("pause_seconds"), + } + state.result["disposition"] = "pause" + state.result["pause_seconds"] = seconds + return before, {"disposition": "pause", "pause_seconds": seconds}, (), "continue" + + if action_type == "drop": + before = { + "disposition": state.result.get("disposition"), + "dropped": state.result.get("dropped"), + } + state.result["disposition"] = "drop" + state.result["dropped"] = True + state.result["suppress_notifications"] = True + return before, {"disposition": "drop", "dropped": True}, (), "stop_orchestration" + + raise ActionValidationError(f"unsupported action {action_type!r}", path=f"{path}.type") + + +def execute_actions( + actions: Sequence[Mapping[str, Any]], + context: Optional[Mapping[str, Any]] = None, + *, + state: Optional[EventActionState] = None, +) -> ActionExecutionResult: + """Execute actions in order and return state plus an explainable trace.""" + + action_list = list(actions) + issues = validate_action_list(action_list) + # Static validation errors are programming/configuration errors. Runtime + # missing-field/template failures are recorded per action below. + if issues: + first = issues[0] + raise ActionValidationError(first.message, path=first.path) + if state is None: + state = EventActionState.from_context(context or {}) + + steps: List[ActionStepResult] = [] + outcome = "continue" + + for index, action in enumerate(action_list): + action_type = action["type"] + failure_mode = action.get("on_failure", "continue") + path = f"actions[{index}]" + + if action_type in EXTRACTION_TYPES: + extraction = extract_variables( + [action], + state.context(), + initial_variables=state.variables, + ) + state.variables.clear() + state.variables.update(extraction.variables) + extraction_step = extraction.steps[0] + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=extraction_step.success, + code=extraction_step.code, + reason=extraction_step.reason, + before=None, + after=dict(extraction_step.variables), + references=(), + failure_mode=extraction_step.failure_mode, + outcome=extraction.outcome, + ) + ) + if extraction.outcome in {"stop_rule", "stop_orchestration"}: + outcome = extraction.outcome + break + continue + + try: + before, after, references, action_outcome = _execute_action(action, state, path=path) + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=True, + code="action_applied", + reason="action applied", + before=before, + after=after, + references=references, + failure_mode=failure_mode, + outcome=action_outcome, + ) + ) + if action_outcome == "stop_orchestration": + outcome = action_outcome + break + except (OrchestrationEvaluationError, ValueError) as exc: + steps.append( + ActionStepResult( + index=index, + action_type=action_type, + success=False, + code=getattr(exc, "code", "action_failed"), + reason=str(exc), + before=None, + after=None, + references=(), + failure_mode=failure_mode, + outcome=failure_mode, + ) + ) + if failure_mode in {"stop_rule", "stop_orchestration"}: + outcome = failure_mode + break + + return ActionExecutionResult(state=state, steps=tuple(steps), outcome=outcome) + + +__all__ = [ + "ActionExecutionResult", + "ActionStepResult", + "ActionValidationError", + "EventActionState", + "SUPPORTED_ACTION_TYPES", + "execute_actions", + "validate_action", + "validate_action_list", +] diff --git a/app/services/orchestration/engine.py b/app/services/orchestration/engine.py new file mode 100644 index 0000000..a958729 --- /dev/null +++ b/app/services/orchestration/engine.py @@ -0,0 +1,288 @@ +"""Deterministic ordered rule-tree execution for Event Orchestration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from .actions import ActionStepResult, EventActionState, execute_actions, validate_action_list +from .conditions import ConditionResult, evaluate_condition_tree, validate_condition_tree +from .errors import ConditionValidationError + + +MAX_RULE_DEPTH = 20 +MAX_RULE_NODES = 512 +VALID_PROCESSING_MODES = frozenset( + {"continue", "stop", "evaluate_children", "children_then_continue"} +) + + +@dataclass(frozen=True) +class RuleExecutionResult: + path: str + name: str + enabled: bool + matched: bool + processing_mode: str + condition: Optional[ConditionResult] = None + actions: Tuple[ActionStepResult, ...] = field(default_factory=tuple) + children: Tuple["RuleExecutionResult", ...] = field(default_factory=tuple) + outcome: str = "continue" + code: str = "rule_evaluated" + reason: str = "rule evaluated" + + def to_dict(self) -> Dict[str, Any]: + result: Dict[str, Any] = { + "path": self.path, + "name": self.name, + "enabled": self.enabled, + "matched": self.matched, + "processing_mode": self.processing_mode, + "actions": [step.to_dict() for step in self.actions], + "children": [child.to_dict() for child in self.children], + "outcome": self.outcome, + "code": self.code, + "reason": self.reason, + } + if self.condition is not None: + result["condition"] = self.condition.to_dict() + return result + + +@dataclass(frozen=True) +class OrchestrationExecutionResult: + state: EventActionState + rules: Tuple[RuleExecutionResult, ...] + outcome: str + matched_rule_count: int + stopped_at: Optional[str] = None + + @property + def context(self) -> Dict[str, Any]: + return self.state.to_dict() + + def to_dict(self) -> Dict[str, Any]: + return { + "context": self.context, + "rules": [rule.to_dict() for rule in self.rules], + "outcome": self.outcome, + "matched_rule_count": self.matched_rule_count, + "stopped_at": self.stopped_at, + } + + +@dataclass +class _EngineCounters: + nodes: int = 0 + matched: int = 0 + stopped_at: Optional[str] = None + + +def _validate_rules(rules: Any, *, path: str = "rules", depth: int = 0, counters: Optional[_EngineCounters] = None) -> None: + if counters is None: + counters = _EngineCounters() + if depth > MAX_RULE_DEPTH: + raise ConditionValidationError("rule tree exceeds the depth limit", path=path) + if not isinstance(rules, list): + raise ConditionValidationError("rules must be a list", path=path) + for index, rule in enumerate(rules): + rule_path = f"{path}[{index}]" + counters.nodes += 1 + if counters.nodes > MAX_RULE_NODES: + raise ConditionValidationError("rule tree exceeds the node limit", path=rule_path) + if not isinstance(rule, dict): + raise ConditionValidationError("rule must be an object", path=rule_path) + mode = rule.get("processing_mode", "continue") + if mode not in VALID_PROCESSING_MODES: + raise ConditionValidationError("invalid rule processing_mode", path=f"{rule_path}.processing_mode") + condition_issues = validate_condition_tree( + rule.get("condition_tree") or {}, + path=f"{rule_path}.condition_tree", + ) + if condition_issues: + first = condition_issues[0] + raise ConditionValidationError(first.message, path=first.path) + actions = rule.get("actions", []) + if not isinstance(actions, list): + raise ConditionValidationError("rule actions must be a list", path=f"{rule_path}.actions") + action_issues = validate_action_list(actions, path=f"{rule_path}.actions") + if action_issues: + first = action_issues[0] + raise ConditionValidationError(first.message, path=first.path) + children = rule.get("children", []) + if not isinstance(children, list): + raise ConditionValidationError("rule children must be a list", path=f"{rule_path}.children") + _validate_rules(children, path=f"{rule_path}.children", depth=depth + 1, counters=counters) + + +def _execute_level( + rules: Sequence[Mapping[str, Any]], + state: EventActionState, + *, + path: str, + depth: int, + counters: _EngineCounters, +) -> Tuple[List[RuleExecutionResult], str]: + traces: List[RuleExecutionResult] = [] + + for index, rule in enumerate(rules): + rule_path = f"{path}[{index}]" + name = str(rule.get("name") or f"Rule {index + 1}") + enabled = bool(rule.get("enabled", True)) + mode = rule.get("processing_mode", "continue") + + if not enabled: + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=False, + matched=False, + processing_mode=mode, + code="rule_disabled", + reason="rule is disabled", + ) + ) + continue + + condition = evaluate_condition_tree(rule.get("condition_tree") or {}, state.context()) + if not condition.matched: + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=False, + processing_mode=mode, + condition=condition, + code="rule_not_matched", + reason="rule condition did not match", + ) + ) + continue + + counters.matched += 1 + action_result = execute_actions(rule.get("actions") or [], state=state) + child_traces: List[RuleExecutionResult] = [] + local_outcome = action_result.outcome + + if local_outcome == "stop_orchestration": + counters.stopped_at = rule_path + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + outcome="stop_orchestration", + code="rule_stopped_orchestration", + reason="an action stopped orchestration processing", + ) + ) + return traces, "stop_orchestration" + + children = rule.get("children") or [] + if mode in {"evaluate_children", "children_then_continue"} and children: + child_traces, child_outcome = _execute_level( + children, + state, + path=f"{rule_path}.children", + depth=depth + 1, + counters=counters, + ) + if child_outcome == "stop_orchestration": + counters.stopped_at = counters.stopped_at or rule_path + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + children=tuple(child_traces), + outcome="stop_orchestration", + code="child_stopped_orchestration", + reason="a child rule stopped orchestration processing", + ) + ) + return traces, "stop_orchestration" + + if mode == "stop": + counters.stopped_at = rule_path + rule_outcome = "stop_orchestration" + code = "processing_mode_stop" + reason = "rule processing_mode stopped orchestration processing" + elif mode == "evaluate_children": + counters.stopped_at = rule_path + rule_outcome = "stop_orchestration" + code = "children_evaluated_then_stop" + reason = "child rules were evaluated and sibling processing stopped" + else: + rule_outcome = local_outcome + code = "rule_matched" + reason = "rule matched and actions were evaluated" + + traces.append( + RuleExecutionResult( + path=rule_path, + name=name, + enabled=True, + matched=True, + processing_mode=mode, + condition=condition, + actions=action_result.steps, + children=tuple(child_traces), + outcome=rule_outcome, + code=code, + reason=reason, + ) + ) + + if rule_outcome == "stop_orchestration": + return traces, "stop_orchestration" + + return traces, "continue" + + +def execute_rule_tree( + rules: Sequence[Mapping[str, Any]], + context: Mapping[str, Any], +) -> OrchestrationExecutionResult: + """Evaluate a published rule definition against an isolated event copy.""" + + rule_list = list(rules) + _validate_rules(rule_list) + state = EventActionState.from_context(context) + counters = _EngineCounters() + traces, outcome = _execute_level( + rule_list, + state, + path="rules", + depth=0, + counters=counters, + ) + if state.result.get("dropped"): + outcome = "drop" + elif outcome == "stop_orchestration": + outcome = "stop" + else: + outcome = "continue" + return OrchestrationExecutionResult( + state=state, + rules=tuple(traces), + outcome=outcome, + matched_rule_count=counters.matched, + stopped_at=counters.stopped_at, + ) + + +__all__ = [ + "OrchestrationExecutionResult", + "RuleExecutionResult", + "execute_rule_tree", +] diff --git a/app/services/orchestration/evaluator.py b/app/services/orchestration/evaluator.py index f7d0154..e7ea848 100644 --- a/app/services/orchestration/evaluator.py +++ b/app/services/orchestration/evaluator.py @@ -66,3 +66,16 @@ def evaluate_rule( "extract_variables", "render_template", ] + +# BEGIN EVENT ORCHESTRATION WS3 EXPORTS +from .actions import ActionExecutionResult, EventActionState, execute_actions +from .engine import OrchestrationExecutionResult, execute_rule_tree + +__all__ += [ + "ActionExecutionResult", + "EventActionState", + "OrchestrationExecutionResult", + "execute_actions", + "execute_rule_tree", +] +# END EVENT ORCHESTRATION WS3 EXPORTS diff --git a/app/services/orchestration/validation.py b/app/services/orchestration/validation.py index 870f4c4..c3332fc 100644 --- a/app/services/orchestration/validation.py +++ b/app/services/orchestration/validation.py @@ -4,6 +4,8 @@ from typing import Any, Dict, List +# EVENT ORCHESTRATION WS3 ACTION VALIDATION +from .actions import validate_action_list from .conditions import validate_condition_tree from .errors import ValidationIssue from .templates import find_templates, validate_template @@ -26,6 +28,8 @@ def validate_rule_definition( ) return {"errors": errors, "warnings": warnings} + errors.extend(validate_action_list(actions, path=f"{path}.actions")) + for index, action in enumerate(actions): action_path = f"{path}.actions[{index}]" if not isinstance(action, dict): diff --git a/app/services/scheduler.py b/app/services/scheduler.py index fb995e5..e5f76b7 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -18,6 +18,7 @@ from app.services.alerts.explain_cleanup import cleanup_alert_explain_traces from app.services.service_catalog.impact_snapshots import capture_scheduled_service_impact_snapshot from app.services.heartbeats.service import process_overdue_heartbeats +from app.modules.common import utc_now logger = logging.getLogger("oncall.scheduler") _scheduler = None @@ -493,7 +494,7 @@ def start_scheduler(): seconds=Config.REMINDER_INTERVAL_SECONDS, max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="reminder_job", replace_existing=True, ) @@ -510,7 +511,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="oncall_shift_email_job", replace_existing=True, ) @@ -528,7 +529,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="oncall_shift_mattermost_job", replace_existing=True, ) @@ -545,7 +546,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="user_notification_rules_job", replace_existing=True, ) @@ -556,7 +557,7 @@ def start_scheduler(): seconds=int(getattr(Config, "ALERT_GROUP_NOTIFICATION_CHECK_INTERVAL_SECONDS", 10)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="alert_group_notification_job", replace_existing=True, ) @@ -573,7 +574,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="incident_responder_expire_job", replace_existing=True, ) @@ -590,7 +591,7 @@ def start_scheduler(): ), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="alert_explain_trace_cleanup_job", replace_existing=True, ) @@ -602,7 +603,7 @@ def start_scheduler(): seconds=int(getattr(Config, "HEARTBEAT_CHECK_INTERVAL_SECONDS", 30)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="heartbeat_overdue_job", replace_existing=True, ) @@ -614,7 +615,7 @@ def start_scheduler(): seconds=int(getattr(Config, "SERVICE_IMPACT_SNAPSHOT_INTERVAL_SECONDS", 300)), max_instances=1, coalesce=True, - next_run_time=datetime.utcnow(), + next_run_time=utc_now(), id="service_impact_snapshot_job", replace_existing=True, ) diff --git a/app/services/serializers/tokens.py b/app/services/serializers/tokens.py index 5117c8a..a51fae9 100644 --- a/app/services/serializers/tokens.py +++ b/app/services/serializers/tokens.py @@ -1,4 +1,5 @@ from datetime import datetime +from app.modules.common import utc_now def serialize_api_token(token): @@ -8,7 +9,7 @@ def serialize_api_token(token): Never expose token_hash or full raw token. """ expires_at = token.expires_at - expired = bool(expires_at and expires_at <= datetime.utcnow()) + expired = bool(expires_at and expires_at <= utc_now()) return { "id": token.id, diff --git a/app/services/service_catalog/analytics.py b/app/services/service_catalog/analytics.py index 6d5236d..8995a47 100644 --- a/app/services/service_catalog/analytics.py +++ b/app/services/service_catalog/analytics.py @@ -7,6 +7,7 @@ from app.modules.db.models import Alert, AlertGroup, Service from app.services.serializers.services import serialize_utc_datetime from app.services.service_catalog.impact import build_single_service_impact_v2 +from app.modules.common import utc_now OPEN_ALERT_GROUP_STATUSES = {"firing", "acknowledged"} @@ -48,7 +49,7 @@ def build_service_analytics_v2(query, *, team_ids=None): requested_team_id = getattr(query, "team_id", None) requested_service_id = getattr(query, "service_id", None) - until = datetime.utcnow() + until = utc_now() since = until - timedelta(days=days) services = _load_services( diff --git a/app/services/service_catalog/impact_snapshots.py b/app/services/service_catalog/impact_snapshots.py index d31a11a..bd23e5a 100644 --- a/app/services/service_catalog/impact_snapshots.py +++ b/app/services/service_catalog/impact_snapshots.py @@ -10,6 +10,7 @@ ) from app.services.serializers.services import serialize_utc_datetime from app.services.service_catalog.impact import build_service_impact_v2 +from app.modules.common import utc_now IMPACTFUL_STATUSES = {"degraded", "partial_outage", "major_outage", "maintenance", "unknown"} STATUS_RANK = { @@ -45,7 +46,7 @@ def capture_service_impact_snapshot(query=None, *, team_ids=None, source="manual query = _normalize_snapshot_query(query) payload = build_service_impact_v2(query, team_ids=team_ids) items = list(payload.get("items") or []) - captured_at = datetime.utcnow() + captured_at = utc_now() summary = _build_snapshot_summary(items, payload.get("summary") or {}) scope = _snapshot_scope(query, team_ids=team_ids) @@ -114,7 +115,7 @@ def list_service_impact_snapshots(query, *, team_ids=None): """Return recent snapshots visible in the requested scope.""" days = int(getattr(query, "days", 7) or 7) limit = int(getattr(query, "limit", 50) or 50) - since = datetime.utcnow() - timedelta(days=days) + since = utc_now() - timedelta(days=days) snapshots = _snapshot_query( since=since, @@ -129,7 +130,7 @@ def list_service_impact_snapshots(query, *, team_ids=None): "window": { "days": days, "since": serialize_utc_datetime(since), - "until": serialize_utc_datetime(datetime.utcnow()), + "until": serialize_utc_datetime(utc_now()), }, "filters": { "team_id": getattr(query, "team_id", None), @@ -144,8 +145,8 @@ def build_service_impact_history(query, *, team_ids=None): days = int(getattr(query, "days", 30) or 30) limit = int(getattr(query, "limit", 25) or 25) bucket = getattr(query, "bucket", "day") or "day" - since = datetime.utcnow() - timedelta(days=days) - until = datetime.utcnow() + since = utc_now() - timedelta(days=days) + until = utc_now() snapshots = list(_snapshot_query( since=since, @@ -191,7 +192,7 @@ def build_service_impact_history(query, *, team_ids=None): def cleanup_service_impact_snapshots(*, retention_days): - cutoff = datetime.utcnow() - timedelta(days=int(retention_days)) + cutoff = utc_now() - timedelta(days=int(retention_days)) old_ids = [ snapshot.id for snapshot in ServiceImpactSnapshot diff --git a/app/services/service_catalog/presets.py b/app/services/service_catalog/presets.py index 8882594..7b9e9db 100644 --- a/app/services/service_catalog/presets.py +++ b/app/services/service_catalog/presets.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import ServiceStandard, ServiceStandardCheck +from app.modules.common import utc_now BASIC_OPERATIONAL_STANDARD_SLUG = "basic-operational-readiness" @@ -133,8 +134,8 @@ def _get_or_create_standard(group, *, actor_user=None): applies_to=BASIC_OPERATIONAL_STANDARD["applies_to"], enabled=BASIC_OPERATIONAL_STANDARD["enabled"], created_by=actor_user.id if actor_user else None, - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ), True except IntegrityError: standard = ServiceStandard.get( @@ -169,8 +170,8 @@ def _get_or_create_check(standard, check_data): required=check_data["required"], enabled=check_data["enabled"], position=check_data["position"], - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + created_at=utc_now(), + updated_at=utc_now(), ), True except IntegrityError: check = ServiceStandardCheck.get( diff --git a/app/services/service_catalog/readiness.py b/app/services/service_catalog/readiness.py index 3cbbbfe..19410ca 100644 --- a/app/services/service_catalog/readiness.py +++ b/app/services/service_catalog/readiness.py @@ -28,6 +28,7 @@ ) from app.services.service_catalog.standards import list_applicable_standards from app.services.service_catalog.timeline import publish_service_event +from app.modules.common import utc_now logger = logging.getLogger("oncall.services.readiness") @@ -42,7 +43,7 @@ class CheckOutcome: def evaluate_service_readiness(service, *, trigger="system", actor_user=None): batch_uid = uuid.uuid4() - evaluated_at = datetime.utcnow() + evaluated_at = utc_now() standards = list_applicable_standards(service) database = Service._meta.database diff --git a/app/services/service_catalog/sli_slo.py b/app/services/service_catalog/sli_slo.py index 0dcd1ac..9ce967b 100644 --- a/app/services/service_catalog/sli_slo.py +++ b/app/services/service_catalog/sli_slo.py @@ -3,6 +3,7 @@ from app.modules.db import maintenance_repo, services_repo from app.modules.db.models import AlertGroup from app.services.serializers.services import serialize_utc_datetime +from app.modules.common import utc_now SLI_TYPE_ACK_LATENCY = "alert_ack_latency" @@ -51,7 +52,7 @@ def normalize_slo_window_days(days): def evaluate_service_slos(slos, *, now=None, persist=True): """Evaluate a list of SLOs and return a dict keyed by SLO id.""" - now = now or datetime.utcnow() + now = now or utc_now() evaluations = {} for slo in slos: @@ -62,7 +63,7 @@ def evaluate_service_slos(slos, *, now=None, persist=True): def evaluate_service_slo(slo, *, now=None, persist=True): """Evaluate one Service Level Objective.""" - now = now or datetime.utcnow() + now = now or utc_now() window_days = normalize_slo_window_days(slo.window_days) since = now - timedelta(days=window_days) sli = slo.sli diff --git a/app/services/service_catalog/standards.py b/app/services/service_catalog/standards.py index 1f99dae..8fd3f29 100644 --- a/app/services/service_catalog/standards.py +++ b/app/services/service_catalog/standards.py @@ -1,6 +1,7 @@ from datetime import datetime from app.modules.db.models import ServiceStandard, ServiceStandardCheck +from app.modules.common import utc_now APPLICABILITY_FIELDS = { @@ -420,8 +421,8 @@ def create_service_standard(data, actor_user=None): values.get("applies_to") ) values["created_by"] = actor_user.id if actor_user else None - values["created_at"] = datetime.utcnow() - values["updated_at"] = datetime.utcnow() + values["created_at"] = utc_now() + values["updated_at"] = utc_now() return ServiceStandard.create(**values) @@ -437,7 +438,7 @@ def update_service_standard(standard, data): for field, value in values.items(): setattr(standard, field, value) - standard.updated_at = datetime.utcnow() + standard.updated_at = utc_now() standard.save() return standard @@ -445,7 +446,7 @@ def update_service_standard(standard, data): def delete_service_standard(standard): database = ServiceStandard._meta.database - now = datetime.utcnow() + now = utc_now() with database.atomic(): ServiceStandardCheck.update( @@ -497,8 +498,8 @@ def create_standard_check(standard, data): values.get("configuration"), ) values["standard"] = standard.id - values["created_at"] = datetime.utcnow() - values["updated_at"] = datetime.utcnow() + values["created_at"] = utc_now() + values["updated_at"] = utc_now() return ServiceStandardCheck.create(**values) @@ -516,7 +517,7 @@ def update_standard_check(check, data): for field, value in values.items(): setattr(check, field, value) - check.updated_at = datetime.utcnow() + check.updated_at = utc_now() check.save() return check @@ -525,7 +526,7 @@ def update_standard_check(check, data): def delete_standard_check(check): check.deleted = True check.enabled = False - check.updated_at = datetime.utcnow() + check.updated_at = utc_now() check.save() return check diff --git a/app/services/service_catalog/timeline.py b/app/services/service_catalog/timeline.py index 23de9c7..7f36a0d 100644 --- a/app/services/service_catalog/timeline.py +++ b/app/services/service_catalog/timeline.py @@ -3,6 +3,7 @@ from peewee import IntegrityError from app.modules.db.models import ServiceEvent +from app.modules.common import utc_now def publish_service_event(service, *, category, event_type, title, summary=None, source="incidentrelay", source_ref=None, dedup_key=None, external_url=None, actor_user=None, actor_type=None, actor_label=None, severity=None, status=None, occurred_at=None, payload=None): @@ -31,7 +32,7 @@ def publish_service_event(service, *, category, event_type, title, summary=None, actor_label=actor_label, severity=severity, status=status, - occurred_at=occurred_at or datetime.utcnow(), + occurred_at=occurred_at or utc_now(), payload=payload or {}, ) except IntegrityError: diff --git a/app/services/user_oncall_status.py b/app/services/user_oncall_status.py index c22bf82..b6c1f24 100644 --- a/app/services/user_oncall_status.py +++ b/app/services/user_oncall_status.py @@ -12,13 +12,14 @@ User, ) from app.services.calendar_service import build_rotation_calendar +from app.modules.common import utc_now DEFAULT_LOOKAHEAD_DAYS = 30 def _utc_naive_now(): - return datetime.utcnow() + return utc_now() def _parse_event_datetime(value): diff --git a/app/views/business_services/routes.py b/app/views/business_services/routes.py index 6f936a6..5199f14 100644 --- a/app/views/business_services/routes.py +++ b/app/views/business_services/routes.py @@ -23,6 +23,7 @@ serialize_business_service_component, ) from app.services.validation import validate_body +from app.modules.common import utc_now business_services_bp = Blueprint("business_services", __name__, url_prefix="/api/business-services") @@ -425,7 +426,7 @@ def set_business_service_manual_status(business_service_id): until = normalize_optional_utc_datetime(payload.until) - if until is not None and until <= datetime.utcnow(): + if until is not None and until <= utc_now(): return jsonify({"error": "Manual status expiration must be in the future"}), 400 item = business_services_repo.set_business_service_manual_status( diff --git a/app/views/calendar_view.py b/app/views/calendar_view.py index fb346cb..741e525 100644 --- a/app/views/calendar_view.py +++ b/app/views/calendar_view.py @@ -12,6 +12,7 @@ get_calendar_feed_by_token, serialize_calendar_feed, ) +from app.modules.common import utc_now calendar_bp = Blueprint("calendar_api", __name__) @@ -66,7 +67,7 @@ def get_calendar(): if not team_id: return jsonify({"error": "team_id is required"}), 400 - start_raw = request.args.get("start") or datetime.utcnow().date().isoformat() + start_raw = request.args.get("start") or utc_now().date().isoformat() end_raw = request.args.get("end") details = [] diff --git a/app/views/integrations_view.py b/app/views/integrations_view.py index 8de4513..7542d99 100644 --- a/app/views/integrations_view.py +++ b/app/views/integrations_view.py @@ -48,6 +48,7 @@ SlackActionError, handle_slack_action, ) +from app.modules.common import utc_now integrations_bp = Blueprint("integrations_api", __name__) @@ -625,7 +626,7 @@ def voice_rule_callback(delivery_id, secret): def _process_voice_rule_callback_event(delivery, event): delivery.provider_status = event.status delivery.provider_payload = event.raw - delivery.updated_at = datetime.utcnow() + delivery.updated_at = utc_now() delivery.save() alert = delivery.alert diff --git a/app/views/profile_view.py b/app/views/profile_view.py index 0d11ec7..4cf4897 100644 --- a/app/views/profile_view.py +++ b/app/views/profile_view.py @@ -13,6 +13,7 @@ from app.services.serializers.tokens import serialize_api_token from app.services.validation import validate_body from app.services.user_oncall_status import get_user_oncall_status +from app.modules.common import utc_now profile_bp = Blueprint("profile_api", __name__) @@ -181,7 +182,7 @@ def create_profile_token(): group = payload.group_id raw_token = create_raw_token() - expires_at = datetime.utcnow() + timedelta(days=payload.days) if payload.days else None + expires_at = utc_now() + timedelta(days=payload.days) if payload.days else None token = tokens_repo.create_token( name=payload.name, diff --git a/app/views/rotations_view.py b/app/views/rotations_view.py index 57a7b54..bf4e284 100644 --- a/app/views/rotations_view.py +++ b/app/views/rotations_view.py @@ -33,6 +33,7 @@ validate_body, ) from app.services.service_catalog.reconciliation import reconcile_rotation_services +from app.modules.common import utc_now rotations_bp = Blueprint("rotations_api", __name__) @@ -650,7 +651,7 @@ def list_rotation_overrides(rotation_id): rotation, ).isoformat(timespec="minutes"), "reason": override.reason, - "expired": override.ends_at <= datetime.utcnow(), + "expired": override.ends_at <= utc_now(), } for override in rotations_repo.list_rotation_overrides( rotation.id, diff --git a/app/views/services/details.py b/app/views/services/details.py index d12f41d..4067341 100644 --- a/app/views/services/details.py +++ b/app/views/services/details.py @@ -15,6 +15,7 @@ from app.services.service_catalog.sli_slo import evaluate_service_slos from app.services.service_catalog.timeline import list_service_events, serialize_service_event, build_next_cursor from app.services.validation import make_error_response +from app.modules.common import utc_now class ServiceDetailsImpactQuery: @@ -44,7 +45,7 @@ def _count_alert_groups(service_id, *conditions): def _service_alert_summary(service_id, *, days): - since = datetime.utcnow() - timedelta(days=days) + since = utc_now() - timedelta(days=days) base_query = _service_alert_group_query(service_id) recent_query = base_query.where(AlertGroup.last_seen_at >= since) @@ -148,7 +149,7 @@ def _service_analytics_payload( timeline, impact=None, ): - until = datetime.utcnow() + until = utc_now() since = until - timedelta(days=days) impact = impact or {} diff --git a/tests/alerts/test_alert_group_notifications.py b/tests/alerts/test_alert_group_notifications.py index 34e3078..db685d4 100644 --- a/tests/alerts/test_alert_group_notifications.py +++ b/tests/alerts/test_alert_group_notifications.py @@ -6,6 +6,7 @@ from app.services.alerts.lifecycle import upsert_alert import app.services.alerts.notification_queue as notification_queue from tests.factories import create_group, create_route, create_team +from app.modules.common import utc_now def _route(group_by=None): @@ -94,7 +95,7 @@ def test_due_group_notification_is_sent(db, monkeypatch): assert created is True alert_group.notification_pending = True - alert_group.notification_due_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.notification_due_at = utc_now() - timedelta(seconds=1) alert_group.notification_reason = "notification" alert_group.save() diff --git a/tests/alerts/test_alert_groups_api.py b/tests/alerts/test_alert_groups_api.py index 138330a..4975fa9 100644 --- a/tests/alerts/test_alert_groups_api.py +++ b/tests/alerts/test_alert_groups_api.py @@ -6,6 +6,7 @@ from app.services.alerts.lifecycle import upsert_alert import app.services.alerts.notification_queue as notification_queue from tests.factories import create_group, create_route, create_team +from app.modules.common import utc_now def _route(group_by): @@ -376,7 +377,7 @@ def test_due_group_notification_is_sent(db, monkeypatch): assert created is True alert_group.notification_pending = True - alert_group.notification_due_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.notification_due_at = utc_now() - timedelta(seconds=1) alert_group.notification_reason = "notification" alert_group.status = "firing" alert_group.save() @@ -487,7 +488,7 @@ def test_new_child_after_notification_schedules_group_interval_update(db, monkey assert alert_group.notification_pending is True assert alert_group.notification_reason == "notification" - alert_group.notification_due_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.notification_due_at = utc_now() - timedelta(seconds=1) alert_group.save() calls = [] diff --git a/tests/alerts/test_alert_trace.py b/tests/alerts/test_alert_trace.py index b6641d7..b79f518 100644 --- a/tests/alerts/test_alert_trace.py +++ b/tests/alerts/test_alert_trace.py @@ -13,6 +13,7 @@ from app.services.alerts.lifecycle import upsert_alert from tests.conftest import admin_headers from tests.factories import create_group, create_route, create_team, create_user, unique +from app.modules.common import utc_now def make_alert_payload(route, **overrides): @@ -527,7 +528,7 @@ def test_cleanup_alert_explain_traces_deletes_old_traces(db): old_trace = alerts_repo.get_alert_explain_trace(old_result.trace_id) fresh_trace = alerts_repo.get_alert_explain_trace(fresh_result.trace_id) - old_trace.started_at = datetime.utcnow() - timedelta(days=40) + old_trace.started_at = utc_now() - timedelta(days=40) old_trace.save() cleanup_result = cleanup_alert_explain_traces(retention_days=30) diff --git a/tests/alerts/test_alerts_service_extended.py b/tests/alerts/test_alerts_service_extended.py index fa1a168..c976a91 100644 --- a/tests/alerts/test_alerts_service_extended.py +++ b/tests/alerts/test_alerts_service_extended.py @@ -22,6 +22,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def normalized_alert(**overrides): @@ -172,7 +173,7 @@ def test_upsert_alert_resolves_existing_alert_and_notifies(monkeypatch, db): assert created is True - alert_group.last_notification_at = datetime.utcnow() + alert_group.last_notification_at = utc_now() alert_group.save() calls = [] @@ -323,7 +324,7 @@ def test_reminder_interval_uses_rotation_before_global_config(db, monkeypatch): instance="host1", ) - now = datetime.utcnow() + now = utc_now() alert_group.last_notification_at = now - timedelta(seconds=31) alert_group.notification_pending = False alert_group.notification_due_at = None @@ -372,7 +373,7 @@ def test_send_unacked_reminders_counts_only_successful_sends(monkeypatch, db): assert successful_group.id != failed_group.id - now = datetime.utcnow() + now = utc_now() for alert_group in (successful_group, failed_group): alert_group = AlertGroup.get_by_id(alert_group.id) alert_group.rotation = rotation.id @@ -457,7 +458,7 @@ def test_zero_reminder_interval_disables_reminders(db): alert_group.last_notification_at = None assert get_alert_reminder_interval(alert_group) == 0 - assert should_send_reminder(alert_group, datetime.utcnow()) is False + assert should_send_reminder(alert_group, utc_now()) is False def test_send_unacked_reminders_does_not_increment_when_no_notification_was_sent( diff --git a/tests/calendar/test_caldav_api.py b/tests/calendar/test_caldav_api.py index 0e70aa3..20d5acf 100644 --- a/tests/calendar/test_caldav_api.py +++ b/tests/calendar/test_caldav_api.py @@ -15,6 +15,7 @@ create_user, unique, ) +from app.modules.common import utc_now def unfold_ics(value: str) -> str: @@ -72,7 +73,7 @@ def create_team_with_rotation(): user = create_user(unique("alice"), group, email=f"{unique('alice')}@example.com") add_user_to_team(team, user) - start_at = datetime.utcnow() - timedelta(hours=1) + start_at = utc_now() - timedelta(hours=1) create_rotation( team, @@ -181,7 +182,7 @@ def test_caldav_rejects_expired_token(client, db): _, _, user = create_team_with_rotation() headers, _ = caldav_headers( user, - expires_at=datetime.utcnow() - timedelta(seconds=1), + expires_at=utc_now() - timedelta(seconds=1), ) response = client.open( diff --git a/tests/calendar/test_calendar_feeds.py b/tests/calendar/test_calendar_feeds.py index 930918f..7c74568 100644 --- a/tests/calendar/test_calendar_feeds.py +++ b/tests/calendar/test_calendar_feeds.py @@ -10,6 +10,7 @@ create_user, unique, ) +from app.modules.common import utc_now def create_team_with_rotation(): @@ -18,7 +19,7 @@ def create_team_with_rotation(): user = create_user(unique("alice"), group, email=f"{unique('alice')}@example.com") add_user_to_team(team, user) - start_at = datetime.utcnow() - timedelta(hours=1) + start_at = utc_now() - timedelta(hours=1) create_rotation( team, diff --git a/tests/factories.py b/tests/factories.py index 1bd39ff..f0a6fc4 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -29,6 +29,7 @@ MatcherPreset, Heartbeat, ) +from app.modules.common import utc_now _counter = 0 @@ -119,7 +120,7 @@ def create_rotation( team_id=team.id, name=name or unique("Rotation"), description=None, - start_at=start_at or datetime.utcnow().replace(microsecond=0), + start_at=start_at or utc_now().replace(microsecond=0), duration_seconds=duration_seconds, reminder_interval_seconds=300, rotation_type="daily", @@ -156,8 +157,8 @@ def create_rotation_override( starts_at: datetime | None = None, ends_at: datetime | None = None, ) -> RotationOverride: - starts_at = starts_at or datetime.utcnow() - timedelta(minutes=5) - ends_at = ends_at or datetime.utcnow() + timedelta(minutes=5) + starts_at = starts_at or utc_now() - timedelta(minutes=5) + ends_at = ends_at or utc_now() + timedelta(minutes=5) return RotationOverride.create( rotation=rotation, user=user, @@ -257,8 +258,8 @@ def create_silence( reason="test silence", matcher_preset=matcher_preset, matchers=matchers or {}, - starts_at=starts_at or datetime.utcnow() - timedelta(minutes=5), - ends_at=ends_at or datetime.utcnow() + timedelta(minutes=5), + starts_at=starts_at or utc_now() - timedelta(minutes=5), + ends_at=ends_at or utc_now() + timedelta(minutes=5), enabled=True, ) @@ -350,7 +351,7 @@ def create_impact_alert_group( priority_order=None, priority_set_manually=False, ): - now = datetime.utcnow() + now = utc_now() alertname = alertname or (service.slug + "-alert") summary = summary or (service.name + " alert") fingerprint = fingerprint or unique(service.slug + "-alert-group") diff --git a/tests/heartbeats/test_heartbeats_instance_regressions.py b/tests/heartbeats/test_heartbeats_instance_regressions.py index cb15b04..0e104dd 100644 --- a/tests/heartbeats/test_heartbeats_instance_regressions.py +++ b/tests/heartbeats/test_heartbeats_instance_regressions.py @@ -12,6 +12,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def _fixture(): @@ -38,7 +39,7 @@ def _create_instance(heartbeat, instance_key, *, now, status="ok", enabled=True) def test_overdue_instance_check_does_not_duplicate_existing_alert_group(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, route, @@ -69,7 +70,7 @@ def test_overdue_instance_check_does_not_duplicate_existing_alert_group(db): def test_disabled_instance_does_not_create_overdue_alert(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, route, @@ -93,7 +94,7 @@ def test_disabled_instance_does_not_create_overdue_alert(db): def test_recovering_one_instance_does_not_hide_second_overdue_instance(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, route, diff --git a/tests/heartbeats/test_heartbeats_service.py b/tests/heartbeats/test_heartbeats_service.py index 9aa7430..529e58c 100644 --- a/tests/heartbeats/test_heartbeats_service.py +++ b/tests/heartbeats/test_heartbeats_service.py @@ -4,6 +4,7 @@ from app.services.heartbeats.service import process_overdue_heartbeats, receive_heartbeat_ping from app.services.integrations.auth import hash_token from tests.factories import create_group, create_heartbeat, create_route, create_service, create_team, create_user, add_user_to_team +from app.modules.common import utc_now def _fixture(): @@ -23,13 +24,13 @@ def test_overdue_heartbeat_creates_regular_alert_group(db): route, service=service, token_hash=hash_token("hb-token"), - last_seen_at=datetime.utcnow() - timedelta(minutes=10), - next_expected_at=datetime.utcnow() - timedelta(minutes=5), + last_seen_at=utc_now() - timedelta(minutes=10), + next_expected_at=utc_now() - timedelta(minutes=5), expected_interval_seconds=60, grace_period_seconds=60, ) - result = process_overdue_heartbeats(now=datetime.utcnow()) + result = process_overdue_heartbeats(now=utc_now()) assert result["processed"] >= 1 assert result["overdue"] == 1 @@ -55,20 +56,20 @@ def test_ping_recovers_overdue_heartbeat_and_resolves_alert(db): route, service=service, token_hash=hash_token("hb-token"), - last_seen_at=datetime.utcnow() - timedelta(minutes=10), - next_expected_at=datetime.utcnow() - timedelta(minutes=5), + last_seen_at=utc_now() - timedelta(minutes=10), + next_expected_at=utc_now() - timedelta(minutes=5), expected_interval_seconds=60, grace_period_seconds=60, ) - process_overdue_heartbeats(now=datetime.utcnow()) + process_overdue_heartbeats(now=utc_now()) heartbeat = heartbeat.__class__.get_by_id(heartbeat.id) group_id = heartbeat.current_alert_group_id recovered, error = receive_heartbeat_ping( "hb-token", payload={"status": "completed", "payload": {"rows_loaded": 10}}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is None @@ -141,7 +142,7 @@ def test_auto_discovered_instance_ping_creates_instance_state(db): item, error = receive_heartbeat_ping( "fleet-token", payload={"status": "completed", "instance": "server-1.example.com"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is None @@ -169,7 +170,7 @@ def test_missing_auto_discovered_instance_pages_individually(db): instance_key="instance", expected_instances_mode="auto", ) - now = datetime.utcnow() + now = utc_now() instance = HeartbeatInstance.create( heartbeat=heartbeat, instance_key="server-1.example.com", @@ -211,7 +212,7 @@ def test_static_instance_rejects_unknown_producer(db): _, error = receive_heartbeat_ping( "static-token", payload={"status": "completed", "instance": "server-2.example.com"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is not None diff --git a/tests/heartbeats/test_heartbeats_service_expanded.py b/tests/heartbeats/test_heartbeats_service_expanded.py index e61aa11..0575069 100644 --- a/tests/heartbeats/test_heartbeats_service_expanded.py +++ b/tests/heartbeats/test_heartbeats_service_expanded.py @@ -4,6 +4,7 @@ from app.services.heartbeats.service import process_overdue_heartbeats, receive_heartbeat_ping from app.services.integrations.auth import hash_token from tests.factories import create_group, create_heartbeat, create_route, create_service, create_team, create_user, add_user_to_team +from app.modules.common import utc_now def _fixture(): @@ -23,13 +24,13 @@ def test_overdue_heartbeat_creates_regular_alert_group(db): route, service=service, token_hash=hash_token("hb-token"), - last_seen_at=datetime.utcnow() - timedelta(minutes=10), - next_expected_at=datetime.utcnow() - timedelta(minutes=5), + last_seen_at=utc_now() - timedelta(minutes=10), + next_expected_at=utc_now() - timedelta(minutes=5), expected_interval_seconds=60, grace_period_seconds=60, ) - result = process_overdue_heartbeats(now=datetime.utcnow()) + result = process_overdue_heartbeats(now=utc_now()) assert result["processed"] >= 1 assert result["overdue"] == 1 @@ -55,20 +56,20 @@ def test_ping_recovers_overdue_heartbeat_and_resolves_alert(db): route, service=service, token_hash=hash_token("hb-token"), - last_seen_at=datetime.utcnow() - timedelta(minutes=10), - next_expected_at=datetime.utcnow() - timedelta(minutes=5), + last_seen_at=utc_now() - timedelta(minutes=10), + next_expected_at=utc_now() - timedelta(minutes=5), expected_interval_seconds=60, grace_period_seconds=60, ) - process_overdue_heartbeats(now=datetime.utcnow()) + process_overdue_heartbeats(now=utc_now()) heartbeat = heartbeat.__class__.get_by_id(heartbeat.id) group_id = heartbeat.current_alert_group_id recovered, error = receive_heartbeat_ping( "hb-token", payload={"status": "completed", "payload": {"rows_loaded": 10}}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is None @@ -141,7 +142,7 @@ def test_auto_discovered_instance_ping_creates_instance_state(db): item, error = receive_heartbeat_ping( "fleet-token", payload={"status": "completed", "instance": "server-1.example.com"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is None @@ -169,7 +170,7 @@ def test_missing_auto_discovered_instance_pages_individually(db): instance_key="instance", expected_instances_mode="auto", ) - now = datetime.utcnow() + now = utc_now() instance = HeartbeatInstance.create( heartbeat=heartbeat, instance_key="server-1.example.com", @@ -211,7 +212,7 @@ def test_static_instance_rejects_unknown_producer(db): _, error = receive_heartbeat_ping( "static-token", payload={"status": "completed", "instance": "server-2.example.com"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is not None @@ -221,7 +222,7 @@ def test_static_instance_rejects_unknown_producer(db): def test_interval_heartbeat_is_not_overdue_before_deadline(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -245,7 +246,7 @@ def test_interval_heartbeat_is_not_overdue_before_deadline(db): def test_overdue_check_does_not_duplicate_existing_alert_group(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -277,7 +278,7 @@ def test_overdue_check_does_not_duplicate_existing_alert_group(db): def test_ping_recovery_does_not_resolve_alert_when_auto_resolve_is_disabled(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -310,7 +311,7 @@ def test_ping_recovery_does_not_resolve_alert_when_auto_resolve_is_disabled(db): def test_ping_payload_persists_run_id_message_and_payload(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -521,7 +522,7 @@ def test_auto_discovered_instance_repeated_ping_updates_existing_state(db): expected_instances_mode="auto", ) - first_seen = datetime.utcnow() + first_seen = utc_now() second_seen = first_seen + timedelta(minutes=1) _, error = receive_heartbeat_ping( @@ -569,7 +570,7 @@ def test_instance_tracking_uses_custom_instance_key_field(db): _, error = receive_heartbeat_ping( "custom-instance-key-token", payload={"status": "completed", "host": "db-1.example.com"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is None @@ -595,7 +596,7 @@ def test_instance_tracking_requires_instance_identifier(db): _, error = receive_heartbeat_ping( "missing-instance-token", payload={"status": "completed"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is not None @@ -606,7 +607,7 @@ def test_static_expected_instance_without_prior_ping_pages_individually(db): from app.modules.db.models import HeartbeatInstance _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -654,7 +655,7 @@ def test_one_healthy_static_instance_does_not_hide_missing_static_instance(db): from app.modules.db.models import HeartbeatInstance _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -721,7 +722,7 @@ def test_ping_recovers_only_overdue_instance_alert(db): from app.modules.db.models import HeartbeatInstance _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -766,7 +767,7 @@ def test_ping_recovers_only_overdue_instance_alert(db): def test_paused_heartbeat_does_not_create_overdue_alert(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -791,7 +792,7 @@ def test_paused_heartbeat_does_not_create_overdue_alert(db): def test_disabled_heartbeat_does_not_create_overdue_alert(db): _, team, service, route = _fixture() - now = datetime.utcnow() + now = utc_now() heartbeat = create_heartbeat( team, @@ -826,7 +827,7 @@ def test_invalid_heartbeat_token_is_rejected(db): _, error = receive_heartbeat_ping( "wrong-token", payload={"status": "completed"}, - now=datetime.utcnow(), + now=utc_now(), ) assert error is not None diff --git a/tests/incidents/test_incidents_api.py b/tests/incidents/test_incidents_api.py index 8a740bf..1cde8d4 100644 --- a/tests/incidents/test_incidents_api.py +++ b/tests/incidents/test_incidents_api.py @@ -11,6 +11,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def disable_responder_notifications(monkeypatch): @@ -941,7 +942,7 @@ def test_expire_due_incident_responders_expires_requested(db, monkeypatch): }, ) - responder.expires_at = datetime.utcnow() - timedelta(seconds=1) + responder.expires_at = utc_now() - timedelta(seconds=1) responder.save(only=[IncidentResponder.expires_at]) result = expire_due_incident_responders(limit=10) diff --git a/tests/notifications/test_browser_push_actions_group_only.py b/tests/notifications/test_browser_push_actions_group_only.py index 0982f49..0d164a1 100644 --- a/tests/notifications/test_browser_push_actions_group_only.py +++ b/tests/notifications/test_browser_push_actions_group_only.py @@ -6,6 +6,7 @@ from app.notifiers.browser_push import service as browser_push from app.services.alerts.lifecycle import upsert_alert from tests.factories import add_user_to_team, create_group, create_route, create_team, create_user +from app.modules.common import utc_now @pytest.fixture(autouse=True) @@ -181,7 +182,7 @@ def test_execute_push_action_rejects_expired_token(db): token_value = payload["action_tokens"]["ack"] token = BrowserPushActionToken.get(BrowserPushActionToken.action == "ack") - token.expires_at = datetime.utcnow() - timedelta(seconds=1) + token.expires_at = utc_now() - timedelta(seconds=1) token.save() result = browser_push.execute_push_action(token_value, "ack") diff --git a/tests/notifications/test_browser_push_notification_service.py b/tests/notifications/test_browser_push_notification_service.py index 4d495cb..213084c 100644 --- a/tests/notifications/test_browser_push_notification_service.py +++ b/tests/notifications/test_browser_push_notification_service.py @@ -11,10 +11,11 @@ create_team, create_user, ) +from app.modules.common import utc_now def create_push_subscription(user): - now = datetime.utcnow() + now = utc_now() return BrowserPushSubscription.create( user=user.id, diff --git a/tests/notifications/test_browser_push_service.py b/tests/notifications/test_browser_push_service.py index 9341515..f9abde3 100644 --- a/tests/notifications/test_browser_push_service.py +++ b/tests/notifications/test_browser_push_service.py @@ -15,6 +15,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def create_push_subscription( @@ -24,7 +25,7 @@ def create_push_subscription( enabled=True, deleted=False, ): - now = datetime.utcnow() + now = utc_now() return BrowserPushSubscription.create( user=user.id, @@ -500,7 +501,7 @@ def test_execute_push_action_rejects_expired_token(db): group=alert_group.id, action="ack", token_hash=browser_push._hash_token(token), - expires_at=datetime.utcnow() - timedelta(seconds=1), + expires_at=utc_now() - timedelta(seconds=1), ) result = browser_push.execute_push_action(token, "ack") diff --git a/tests/notifications/test_notification_regressions.py b/tests/notifications/test_notification_regressions.py index 667bf46..59a6552 100644 --- a/tests/notifications/test_notification_regressions.py +++ b/tests/notifications/test_notification_regressions.py @@ -18,6 +18,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def _create_alert_route(*, rotation=None): @@ -69,7 +70,7 @@ def test_due_group_notification_without_delivery_target_is_skipped(db, monkeypat ) alert_group.notification_pending = True - alert_group.notification_due_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.notification_due_at = utc_now() - timedelta(seconds=1) alert_group.notification_reason = "notification" alert_group.last_notification_at = None alert_group.status = "firing" @@ -121,10 +122,10 @@ def test_due_group_update_without_delivery_target_does_not_change_last_notificat dedup_key="dedup-due-update-no-target", ) - previous_notification_at = datetime.utcnow() - timedelta(minutes=10) + previous_notification_at = utc_now() - timedelta(minutes=10) alert_group.notification_pending = True - alert_group.notification_due_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.notification_due_at = utc_now() - timedelta(seconds=1) alert_group.notification_reason = "update" alert_group.last_notification_at = previous_notification_at alert_group.status = "firing" @@ -172,9 +173,9 @@ def test_send_unacked_reminders_skips_group_with_pending_notification(db, monkey alert_group.status = "firing" alert_group.notification_pending = True - alert_group.notification_due_at = datetime.utcnow() + timedelta(seconds=30) + alert_group.notification_due_at = utc_now() + timedelta(seconds=30) alert_group.notification_reason = "notification" - alert_group.last_notification_at = datetime.utcnow() - timedelta(minutes=10) + alert_group.last_notification_at = utc_now() - timedelta(minutes=10) alert_group.reminder_count = 0 alert_group.save() @@ -415,7 +416,7 @@ def test_due_route_less_group_notification_uses_service_policy(db, monkeypatch): alert_count=1, firing_count=1, notification_pending=True, - notification_due_at=datetime.utcnow() - timedelta(seconds=1), + notification_due_at=utc_now() - timedelta(seconds=1), notification_reason="notification", last_notification_at=None, ) diff --git a/tests/notifications/test_notification_rules_delivery.py b/tests/notifications/test_notification_rules_delivery.py index 96046f3..a735066 100644 --- a/tests/notifications/test_notification_rules_delivery.py +++ b/tests/notifications/test_notification_rules_delivery.py @@ -4,6 +4,7 @@ from app.services.alerts.lifecycle import upsert_alert from app.services.notifications import rules from tests.factories import create_group, create_route, create_team, create_user +from app.modules.common import utc_now class FakeDirectNotifier: @@ -65,9 +66,9 @@ def _create_due_delivery(user, alert_group, rule, *, event_type="notification"): method=rule.method if rule else rules.NOTIFICATION_METHOD_EMAIL, event_type=event_type, status="pending", - scheduled_at=datetime.utcnow() - timedelta(seconds=1), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() - timedelta(seconds=1), + created_at=utc_now(), + updated_at=utc_now(), ) @@ -146,9 +147,9 @@ def test_unsupported_user_notification_method_is_marked_failed(db): method="sms", event_type="notification", status="pending", - scheduled_at=datetime.utcnow() - timedelta(seconds=1), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() - timedelta(seconds=1), + created_at=utc_now(), + updated_at=utc_now(), ) assert rules.send_delivery(delivery) == 0 diff --git a/tests/notifications/test_notification_rules_group_only.py b/tests/notifications/test_notification_rules_group_only.py index 074bd2e..5951d59 100644 --- a/tests/notifications/test_notification_rules_group_only.py +++ b/tests/notifications/test_notification_rules_group_only.py @@ -5,6 +5,7 @@ from app.services.alerts.actions import resolve_alert from app.services.alerts.lifecycle import upsert_alert from tests.factories import add_user_to_team, create_group, create_route, create_team, create_user +from app.modules.common import utc_now def _group_with_assignee(): @@ -109,7 +110,7 @@ def test_custom_delayed_user_notification_is_skipped_when_group_no_longer_firing resolve_alert(alert_group.id, user_id=user.id) - delivery.scheduled_at = datetime.utcnow() - timedelta(seconds=1) + delivery.scheduled_at = utc_now() - timedelta(seconds=1) delivery.save() monkeypatch.setattr( @@ -151,7 +152,7 @@ def test_resolved_user_notification_is_not_skipped_when_group_is_resolved(db, mo assert created == 0 delivery = UserNotificationDelivery.get() - delivery.scheduled_at = datetime.utcnow() - timedelta(seconds=1) + delivery.scheduled_at = utc_now() - timedelta(seconds=1) delivery.save() calls = [] diff --git a/tests/notifications/test_notification_rules_service.py b/tests/notifications/test_notification_rules_service.py index bac849b..cc3576d 100644 --- a/tests/notifications/test_notification_rules_service.py +++ b/tests/notifications/test_notification_rules_service.py @@ -11,6 +11,7 @@ create_team, create_user, ) +from app.modules.common import utc_now def create_assigned_group(user, *, status="firing", severity="critical"): @@ -240,7 +241,7 @@ def test_enqueue_user_notifications_creates_pending_delayed_rule(db, monkeypatch assert delivery.rule_id == rule.id assert delivery.method == "browser_push" assert delivery.status == "pending" - assert delivery.scheduled_at > datetime.utcnow() + assert delivery.scheduled_at > utc_now() def test_process_due_user_notifications_sends_due_delivery(db, monkeypatch): @@ -263,9 +264,9 @@ def test_process_due_user_notifications_sends_due_delivery(db, monkeypatch): method="browser_push", event_type="notification", status="pending", - scheduled_at=datetime.utcnow() - timedelta(seconds=1), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() - timedelta(seconds=1), + created_at=utc_now(), + updated_at=utc_now(), ) calls = [] @@ -311,9 +312,9 @@ def test_process_due_user_notifications_does_not_send_future_delivery(db, monkey method="browser_push", event_type="notification", status="pending", - scheduled_at=datetime.utcnow() + timedelta(minutes=5), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() + timedelta(minutes=5), + created_at=utc_now(), + updated_at=utc_now(), ) monkeypatch.setattr( @@ -348,9 +349,9 @@ def test_process_due_user_notifications_skips_if_alert_no_longer_firing( method="browser_push", event_type="notification", status="pending", - scheduled_at=datetime.utcnow() - timedelta(seconds=1), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() - timedelta(seconds=1), + created_at=utc_now(), + updated_at=utc_now(), ) monkeypatch.setattr( @@ -392,9 +393,9 @@ def test_process_due_user_notifications_does_not_send_already_processing_deliver method="browser_push", event_type="notification", status="processing", - scheduled_at=datetime.utcnow() - timedelta(seconds=1), - created_at=datetime.utcnow(), - updated_at=datetime.utcnow(), + scheduled_at=utc_now() - timedelta(seconds=1), + created_at=utc_now(), + updated_at=utc_now(), ) monkeypatch.setattr( diff --git a/tests/orchestration/test_orchestration_action_validation.py b/tests/orchestration/test_orchestration_action_validation.py new file mode 100644 index 0000000..cd83d11 --- /dev/null +++ b/tests/orchestration/test_orchestration_action_validation.py @@ -0,0 +1,42 @@ +from app.services.orchestration.validation import validate_rule_definition + + +def test_publication_validation_accepts_supported_actions(): + result = validate_rule_definition( + {}, + [ + {"type": "set_title", "value": "{{ labels.alertname }}"}, + {"type": "set_team", "team_id": 7}, + {"type": "set_grouping", "group_key": "{{ labels.service }}", "window_seconds": 60}, + {"type": "suppress"}, + ], + ) + + assert result["errors"] == [] + + +def test_publication_validation_rejects_unknown_or_malformed_actions(): + result = validate_rule_definition( + {}, + [ + {"type": "execute_shell", "value": "rm -rf /"}, + {"type": "set_team", "team_id": "{{ variables.team }}"}, + {"type": "pause", "seconds": 0}, + ], + ) + + codes = {issue.code for issue in result["errors"]} + assert "unsupported_action" in codes + assert "invalid_action" in codes + assert "invalid_pause_duration" in codes + +def test_publication_validation_rejects_noncanonical_add_label_action(): + result = validate_rule_definition( + {}, + [{"type": "add_label", "key": "tier", "value": "data"}], + ) + + assert [(issue.path, issue.code) for issue in result["errors"]] == [ + ("rule.actions[0].type", "unsupported_action"), + ] + diff --git a/tests/orchestration/test_orchestration_actions.py b/tests/orchestration/test_orchestration_actions.py new file mode 100644 index 0000000..4159b1a --- /dev/null +++ b/tests/orchestration/test_orchestration_actions.py @@ -0,0 +1,199 @@ +import copy + +import pytest + +from app.services.orchestration.actions import ( + ActionValidationError, + execute_actions, + validate_action_list, +) +from app.services.orchestration.fields import build_context + + +def test_actions_mutate_event_with_templates_and_keep_input_immutable(): + context = build_context( + event={"title": "old", "message": "old", "severity": "warning"}, + labels={"host": "db-01"}, + ) + original = copy.deepcopy(context) + + result = execute_actions( + [ + {"type": "set_title", "value": "Database {{ labels.host | upper }}"}, + {"type": "set_message", "template": "{{ event.title }} is unavailable"}, + {"type": "set_severity", "value": "critical"}, + {"type": "set_label", "name": "orchestrated", "value": True}, + ], + context, + ) + + assert result.context["event"]["title"] == "Database DB-01" + assert result.context["event"]["message"] == "Database DB-01 is unavailable" + assert result.context["event"]["severity"] == "critical" + assert result.context["labels"]["orchestrated"] == "true" + assert context == original + assert [step.success for step in result.steps] == [True, True, True, True] + assert result.steps[0].references == ("labels.host",) + + +def test_extraction_action_can_feed_later_template_action(): + result = execute_actions( + [ + { + "type": "extract_regex", + "source": "event.message", + "pattern": r"host=(?P[a-z0-9-]+)", + }, + {"type": "set_title", "value": "Failure on {{ variables.host }}"}, + ], + build_context(event={"message": "host=api-17 status=down"}), + ) + + assert result.context["variables"]["host"] == "api-17" + assert result.context["event"]["title"] == "Failure on api-17" + assert result.steps[0].code == "extraction_succeeded" + + +def test_routing_policy_and_grouping_actions_record_explicit_result(): + result = execute_actions( + [ + {"type": "set_route", "route_id": 11}, + {"type": "set_team", "team_id": 12}, + {"type": "set_service", "service_id": 13}, + {"type": "set_escalation_policy", "escalation_policy_id": 21}, + {"type": "set_notification_policy", "notification_policy_id": 22}, + {"type": "set_priority_policy", "priority_policy_id": 23}, + { + "type": "set_grouping", + "dedup_key": "{{ labels.alertname }}:{{ labels.instance }}", + "group_key": "{{ labels.alertname }}", + "window_seconds": 300, + "strategy": "content", + }, + ], + build_context(labels={"alertname": "DiskFull", "instance": "db1"}), + ) + + context = result.context + assert context["route"] == {"id": 11} + assert context["team"] == {"id": 12} + assert context["service"] == {"id": 13} + assert context["result"]["routing"] == { + "route_id": 11, + "team_id": 12, + "service_id": 13, + } + assert context["result"]["policies"] == { + "escalation_policy_id": 21, + "notification_policy_id": 22, + "priority_policy_id": 23, + } + assert context["event"]["dedup_key"] == "DiskFull:db1" + assert context["event"]["group_key"] == "DiskFull" + assert context["result"]["grouping"]["window_seconds"] == 300 + + +def test_custom_fields_labels_notes_and_removals(): + result = execute_actions( + [ + {"type": "set_custom_field", "name": "runbook", "value": {"id": 7}}, + {"type": "set_label", "name": "environment", "value": "prod"}, + {"type": "add_note", "value": "classified as {{ labels.environment }}"}, + {"type": "remove_custom_field", "name": "old"}, + {"type": "remove_label", "name": "obsolete"}, + ], + build_context( + event={"custom_details": {"old": "x"}}, + labels={"obsolete": "1"}, + ), + ) + + assert result.context["event"]["custom_details"] == {"runbook": {"id": 7}} + assert result.context["labels"] == {"environment": "prod"} + assert result.context["result"]["notes"] == ["classified as prod"] + + +def test_runtime_failure_can_continue_and_is_explained(): + result = execute_actions( + [ + { + "type": "set_title", + "value": "{{ variables.missing }}", + "on_failure": "continue", + }, + {"type": "set_severity", "value": "critical"}, + ], + build_context(event={"title": "unchanged"}), + ) + + assert result.outcome == "continue" + assert result.context["event"]["title"] == "unchanged" + assert result.context["event"]["severity"] == "critical" + assert result.steps[0].success is False + assert result.steps[0].code == "invalid_template" + assert result.steps[1].success is True + + +def test_runtime_failure_can_stop_orchestration(): + result = execute_actions( + [ + { + "type": "set_title", + "value_from": "variables.missing", + "on_failure": "stop_orchestration", + }, + {"type": "set_severity", "value": "critical"}, + ], + build_context(event={"severity": "warning"}), + ) + + assert result.outcome == "stop_orchestration" + assert result.context["event"]["severity"] == "warning" + assert len(result.steps) == 1 + assert result.steps[0].success is False + + +def test_disposition_actions_are_explicit_and_drop_is_terminal(): + suppressed = execute_actions([{"type": "suppress"}], build_context()) + assert suppressed.context["result"]["disposition"] == "suppress" + assert suppressed.context["result"]["suppress_notifications"] is True + + paused = execute_actions([{"type": "pause", "seconds": 120}], build_context()) + assert paused.context["result"]["disposition"] == "pause" + assert paused.context["result"]["pause_seconds"] == 120 + + dropped = execute_actions( + [ + {"type": "drop"}, + {"type": "set_title", "value": "must not run"}, + ], + build_context(event={"title": "original"}), + ) + assert dropped.outcome == "stop_orchestration" + assert dropped.context["result"]["disposition"] == "drop" + assert dropped.context["event"]["title"] == "original" + assert len(dropped.steps) == 1 + + +def test_set_event_action_accepts_only_trigger_or_resolve(): + result = execute_actions( + [{"type": "set_event_action", "value": "resolve"}], + build_context(), + ) + assert result.context["event"]["event_action"] == "resolve" + + issues = validate_action_list([{"type": "set_event_action", "value": "close"}]) + assert any(issue.code == "invalid_event_action" for issue in issues) + + +def test_invalid_static_reference_is_rejected_before_execution(): + issues = validate_action_list([{"type": "set_team", "team_id": "dynamic"}]) + assert any(issue.code == "invalid_action" for issue in issues) + + with pytest.raises(ActionValidationError): + execute_actions([{"type": "set_team", "team_id": "dynamic"}], build_context()) + + +def test_unknown_action_is_rejected(): + issues = validate_action_list([{"type": "run_python", "value": "print(1)"}]) + assert issues[0].code == "unsupported_action" diff --git a/tests/orchestration/test_orchestration_engine.py b/tests/orchestration/test_orchestration_engine.py new file mode 100644 index 0000000..2cf70c6 --- /dev/null +++ b/tests/orchestration/test_orchestration_engine.py @@ -0,0 +1,147 @@ +import copy + +from app.services.orchestration.engine import execute_rule_tree +from app.services.orchestration.fields import build_context + + +def _rule(name, *, condition=None, actions=None, mode="continue", children=None, enabled=True): + return { + "name": name, + "enabled": enabled, + "condition_tree": condition or {}, + "actions": actions or [], + "processing_mode": mode, + "children": children or [], + } + + +def test_continue_rules_run_in_order_and_later_conditions_see_mutations(): + rules = [ + _rule( + "classify", + actions=[ + {"type": "set_label", "name": "tier", "value": "database"}, + {"type": "set_severity", "value": "critical"}, + ], + ), + _rule( + "route", + condition={"field": "labels.tier", "operator": "equals", "value": "database"}, + actions=[{"type": "set_team", "team_id": 9}], + ), + ] + + result = execute_rule_tree(rules, build_context(event={"severity": "warning"})) + + assert result.outcome == "continue" + assert result.matched_rule_count == 2 + assert result.context["event"]["severity"] == "critical" + assert result.context["team"] == {"id": 9} + assert [trace.matched for trace in result.rules] == [True, True] + + +def test_stop_processing_mode_prevents_later_siblings(): + rules = [ + _rule("first", actions=[{"type": "set_title", "value": "first"}], mode="stop"), + _rule("second", actions=[{"type": "set_title", "value": "second"}]), + ] + + result = execute_rule_tree(rules, build_context()) + + assert result.outcome == "stop" + assert result.stopped_at == "rules[0]" + assert result.context["event"]["title"] == "first" + assert len(result.rules) == 1 + + +def test_evaluate_children_runs_children_then_stops_siblings(): + rules = [ + _rule( + "parent", + actions=[{"type": "set_label", "name": "parent", "value": "yes"}], + mode="evaluate_children", + children=[ + _rule( + "child", + condition={"field": "labels.parent", "operator": "equals", "value": "yes"}, + actions=[{"type": "set_title", "value": "child"}], + ) + ], + ), + _rule("sibling", actions=[{"type": "set_title", "value": "sibling"}]), + ] + + result = execute_rule_tree(rules, build_context()) + + assert result.outcome == "stop" + assert result.context["event"]["title"] == "child" + assert len(result.rules) == 1 + assert result.rules[0].children[0].matched is True + + +def test_children_then_continue_returns_to_parent_siblings(): + rules = [ + _rule( + "parent", + mode="children_then_continue", + children=[_rule("child", actions=[{"type": "set_title", "value": "child"}])], + ), + _rule("sibling", actions=[{"type": "set_message", "value": "sibling ran"}]), + ] + + result = execute_rule_tree(rules, build_context()) + + assert result.outcome == "continue" + assert result.context["event"]["title"] == "child" + assert result.context["event"]["message"] == "sibling ran" + assert len(result.rules) == 2 + + +def test_disabled_and_unmatched_rules_have_trace_without_actions(): + rules = [ + _rule("disabled", enabled=False, actions=[{"type": "drop"}]), + _rule( + "unmatched", + condition={"field": "severity", "operator": "equals", "value": "critical"}, + actions=[{"type": "drop"}], + ), + ] + + result = execute_rule_tree(rules, build_context(event={"severity": "warning"})) + + assert result.outcome == "continue" + assert result.matched_rule_count == 0 + assert result.rules[0].code == "rule_disabled" + assert result.rules[1].code == "rule_not_matched" + assert result.context["result"]["dropped"] is False + + +def test_drop_action_stops_entire_nested_tree(): + rules = [ + _rule( + "parent", + mode="children_then_continue", + children=[_rule("child", actions=[{"type": "drop"}])], + ), + _rule("must not run", actions=[{"type": "set_title", "value": "wrong"}]), + ] + + result = execute_rule_tree(rules, build_context(event={"title": "original"})) + + assert result.outcome == "drop" + assert result.context["event"]["title"] == "original" + assert result.context["result"]["dropped"] is True + assert result.stopped_at == "rules[0].children[0]" + assert len(result.rules) == 1 + + +def test_engine_does_not_mutate_input_context(): + context = build_context(event={"title": "original"}, labels={"a": "b"}) + original = copy.deepcopy(context) + + execute_rule_tree( + [_rule("change", actions=[{"type": "set_title", "value": "changed"}])], + context, + ) + + assert context == original diff --git a/tests/orchestration/test_orchestration_versioning.py b/tests/orchestration/test_orchestration_versioning.py index 5f8339a..939b21e 100644 --- a/tests/orchestration/test_orchestration_versioning.py +++ b/tests/orchestration/test_orchestration_versioning.py @@ -74,7 +74,7 @@ def _rules(service_id=None): "operator": "eq", "value": "database", }, - "actions": [{"type": "add_label", "key": "tier", "value": "data"}], + "actions": [{"type": "set_label", "name": "tier", "value": "data"}], "processing_mode": "stop", } ], diff --git a/tests/routes/matchers/test_silence_matcher_presets.py b/tests/routes/matchers/test_silence_matcher_presets.py index 25b9e11..2ddce36 100644 --- a/tests/routes/matchers/test_silence_matcher_presets.py +++ b/tests/routes/matchers/test_silence_matcher_presets.py @@ -8,10 +8,11 @@ create_team, unique, ) +from app.modules.common import utc_now def silence_payload(team, *, matcher_preset_id=None, matchers=None): - now = datetime.utcnow() + now = utc_now() return { "team_id": team.id, diff --git a/tests/routes/test_reminder_interval.py b/tests/routes/test_reminder_interval.py index a25287b..f62cc29 100644 --- a/tests/routes/test_reminder_interval.py +++ b/tests/routes/test_reminder_interval.py @@ -7,6 +7,7 @@ from app.modules.db.models import AlertEvent from app.services.alerts.reminders import should_send_reminder, send_unacked_reminders from tests.factories import attach_channel, create_alert, create_channel, create_group, create_route, create_rotation, create_team +from app.modules.common import utc_now def _rotation_payload(reminder_interval_seconds): @@ -55,9 +56,9 @@ def test_should_send_reminder_returns_false_when_rotation_interval_is_zero(db): route = create_route(team, rotation=rotation) alert = create_alert(route) alert.rotation = rotation - alert.last_notification_at = datetime.utcnow() - timedelta(days=1) + alert.last_notification_at = utc_now() - timedelta(days=1) - assert should_send_reminder(alert, datetime.utcnow()) is False + assert should_send_reminder(alert, utc_now()) is False def test_send_unacked_reminders_skips_rotation_with_zero_interval(monkeypatch, db): @@ -73,7 +74,7 @@ def test_send_unacked_reminders_skips_rotation_with_zero_interval(monkeypatch, d alert = create_alert(route) alert.rotation = rotation - alert.last_notification_at = datetime.utcnow() - timedelta(days=1) + alert.last_notification_at = utc_now() - timedelta(days=1) alert.save() def fail_notify(*args, **kwargs): diff --git a/tests/services/business_services/test_business_service_manual_status.py b/tests/services/business_services/test_business_service_manual_status.py index a116757..867b52c 100644 --- a/tests/services/business_services/test_business_service_manual_status.py +++ b/tests/services/business_services/test_business_service_manual_status.py @@ -4,6 +4,7 @@ from app.modules.db import business_services_repo from app.services.business_services.status import apply_business_service_status from tests.factories import create_group, create_service, create_team, unique +from app.modules.common import utc_now def create_manual_status_fixture(): @@ -40,7 +41,7 @@ def test_manual_status_override_wins_over_calculated_status(db): business_service.id, manual_status="degraded", message="Customer impact is limited", - until=datetime.utcnow() + timedelta(hours=1), + until=utc_now() + timedelta(hours=1), user_id=None, ) @@ -66,7 +67,7 @@ def test_expired_manual_status_is_ignored_and_cleared(db): business_service.id, manual_status="operational", message="Expired override", - until=datetime.utcnow() - timedelta(minutes=1), + until=utc_now() - timedelta(minutes=1), user_id=None, ) @@ -94,7 +95,7 @@ def test_clear_manual_status_reverts_to_calculated_status(db): business_service.id, manual_status="degraded", message="Manual degraded", - until=datetime.utcnow() + timedelta(hours=1), + until=utc_now() + timedelta(hours=1), user_id=None, ) @@ -126,7 +127,7 @@ def test_manual_status_source_change_writes_history(db): business_service.id, manual_status="degraded", message="Manual confirmation", - until=datetime.utcnow() + timedelta(hours=1), + until=utc_now() + timedelta(hours=1), user_id=None, ) @@ -158,7 +159,7 @@ def test_set_manual_status_api(client, db, auth_headers): json={ "status": "degraded", "message": "Limited customer impact", - "until": (datetime.utcnow() + timedelta(hours=1)).isoformat(), + "until": (utc_now() + timedelta(hours=1)).isoformat(), }, headers=auth_headers, ) @@ -184,7 +185,7 @@ def test_clear_manual_status_api(client, db, auth_headers): business_service.id, manual_status="degraded", message="Manual degraded", - until=datetime.utcnow() + timedelta(hours=1), + until=utc_now() + timedelta(hours=1), user_id=None, ) diff --git a/tests/services/business_services/test_business_services_acceptance.py b/tests/services/business_services/test_business_services_acceptance.py index 4e6b6ff..c180354 100644 --- a/tests/services/business_services/test_business_services_acceptance.py +++ b/tests/services/business_services/test_business_services_acceptance.py @@ -9,6 +9,7 @@ from app.services.business_services.impact import refresh_business_impacts_for_group from app.services.business_services.status import apply_business_service_status from tests.factories import create_group, create_impact_alert_group, create_service, create_team, unique +from app.modules.common import utc_now def create_business_service_with_component(): @@ -142,7 +143,7 @@ def test_manual_status_set_and_clear_return_details_payload(client, db, auth_hea json={ "status": "degraded", "message": "Limited customer impact", - "until": (datetime.utcnow() + timedelta(hours=1)).isoformat(), + "until": (utc_now() + timedelta(hours=1)).isoformat(), }, headers=auth_headers, ) diff --git a/tests/services/business_services/test_business_services_edge_cases.py b/tests/services/business_services/test_business_services_edge_cases.py index 671b6d0..9b14b60 100644 --- a/tests/services/business_services/test_business_services_edge_cases.py +++ b/tests/services/business_services/test_business_services_edge_cases.py @@ -13,10 +13,11 @@ from app.services.business_services.impact import refresh_business_impacts_for_group from app.services.business_services.status import apply_business_service_status from tests.factories import create_group, create_impact_alert_group, create_service, create_team, unique +from app.modules.common import utc_now def create_unmapped_alert_group(team, status="firing"): - now = datetime.utcnow() + now = utc_now() fingerprint = unique("business-impact-no-service") labels = { diff --git a/tests/services/service_catalog/test_service_timeline.py b/tests/services/service_catalog/test_service_timeline.py index feaa18f..39f9824 100644 --- a/tests/services/service_catalog/test_service_timeline.py +++ b/tests/services/service_catalog/test_service_timeline.py @@ -2,6 +2,7 @@ from app.services.service_catalog.timeline import build_next_cursor, list_service_events, publish_service_event, serialize_service_event from tests.factories import create_group, create_service, create_team +from app.modules.common import utc_now def test_publish_service_event_uses_service_scope_snapshot(): @@ -32,7 +33,7 @@ def test_list_service_events_orders_newest_first(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() older = publish_service_event(service, category="configuration", event_type="service.created", title="Older", occurred_at=now - timedelta(minutes=5)) newer = publish_service_event(service, category="status", event_type="service.status_changed", title="Newer", occurred_at=now) @@ -59,7 +60,7 @@ def test_list_service_events_uses_stable_cursor(): group = create_group() team = create_team(group) service = create_service(team) - occurred_at = datetime.utcnow() + occurred_at = utc_now() first = publish_service_event(service, category="configuration", event_type="service.updated", title="First", occurred_at=occurred_at) second = publish_service_event(service, category="configuration", event_type="service.updated", title="Second", occurred_at=occurred_at) diff --git a/tests/services/service_catalog/test_sli_slo_evaluation.py b/tests/services/service_catalog/test_sli_slo_evaluation.py index fb21699..c704659 100644 --- a/tests/services/service_catalog/test_sli_slo_evaluation.py +++ b/tests/services/service_catalog/test_sli_slo_evaluation.py @@ -20,6 +20,7 @@ validate_slo_for_sli, ) from tests.factories import create_group, create_service, create_team +from app.modules.common import utc_now def _alert_group(service, *, first_seen_at, acknowledged_at=None, resolved_at=None, severity="critical", priority_slug="p1", priority_order=1, status="resolved", key="1"): @@ -75,7 +76,7 @@ def test_ack_latency_slo_calculates_percent_good_and_persists_measurement(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_ACK_LATENCY, severity="critical") slo = _slo(service, sli, target_percent_basis_points=5000, threshold_seconds=900) @@ -116,7 +117,7 @@ def test_ack_latency_slo_marks_breached_when_percent_is_below_target(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_ACK_LATENCY, severity="critical") slo = _slo(service, sli, target_percent_basis_points=9500, threshold_seconds=900) @@ -139,7 +140,7 @@ def test_incident_availability_merges_overlapping_intervals_and_calculates_budge group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_INCIDENT_AVAILABILITY, configuration={"priority_scope": ["p1", "p2"]}) slo = _slo( @@ -179,7 +180,7 @@ def test_incident_availability_uses_priority_scope_instead_of_severity(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli( service, @@ -222,7 +223,7 @@ def test_incident_count_slo_uses_value_lte_comparison(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_INCIDENT_COUNT, configuration={"priority_scope": ["p1", "p2"]}) slo = _slo( @@ -265,7 +266,7 @@ def test_resolve_latency_slo_calculates_good_bad_counts(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_RESOLVE_LATENCY, severity="critical") slo = _slo(service, sli, target_percent_basis_points=5000, threshold_seconds=900) @@ -297,7 +298,7 @@ def test_ack_latency_pending_open_alert_marks_slo_at_risk(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli(service, SLI_TYPE_ACK_LATENCY, severity="critical") slo = _slo( @@ -338,7 +339,7 @@ def test_incident_availability_subtracts_service_maintenance_window(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli( service, @@ -394,7 +395,7 @@ def test_incident_count_ignores_non_matching_priority_scope(): group = create_group() team = create_team(group) service = create_service(team) - now = datetime.utcnow() + now = utc_now() sli = _sli( service, diff --git a/tests/services/test_maintenance_windows.py b/tests/services/test_maintenance_windows.py index 79a25ea..6173982 100644 --- a/tests/services/test_maintenance_windows.py +++ b/tests/services/test_maintenance_windows.py @@ -17,6 +17,7 @@ ) from app.modules.db.models import AuditLog from app.modules.common import as_naive_datetime, as_utc_aware +from app.modules.common import utc_now def response_items(response): @@ -172,7 +173,7 @@ def assert_no_pending_group_notification(incident): def create_window_payload(scope, *, behavior="suppress_notifications"): - now = datetime.utcnow() + now = utc_now() return { "name": "Payments deploy", @@ -230,7 +231,7 @@ def test_create_maintenance_window_requires_scope(client, db): def test_create_maintenance_window_rejects_invalid_dates(client, db): group, team, route, service, user, headers = create_manager_context() - now = datetime.utcnow() + now = utc_now() response = client.post( "/api/maintenance-windows", @@ -430,7 +431,7 @@ def create_active_service_maintenance_window( service, behavior="create_maintenance_incident", ): - now = datetime.utcnow() + now = utc_now() window = MaintenanceWindow.create( group=team.group, @@ -947,7 +948,7 @@ def test_recurring_maintenance_window_api_returns_active_occurrence(client, db): def create_active_team_maintenance_window(*, team, behavior="suppress_notifications"): - now = datetime.utcnow() + now = utc_now() window = MaintenanceWindow.create( group=team.group, @@ -1085,7 +1086,7 @@ def test_route_serializer_returns_active_team_maintenance(client, db): def test_service_serializer_does_not_return_finished_maintenance(client, db): group, team, route, service, user, headers = create_manager_context() - now = datetime.utcnow() + now = utc_now() window = MaintenanceWindow.create( group=team.group, diff --git a/tests/services/test_service_impact_snapshots.py b/tests/services/test_service_impact_snapshots.py index 1d5a23a..70c34cd 100644 --- a/tests/services/test_service_impact_snapshots.py +++ b/tests/services/test_service_impact_snapshots.py @@ -14,6 +14,7 @@ create_service_dependency, create_team, ) +from app.modules.common import utc_now @pytest.fixture(autouse=True) @@ -157,7 +158,7 @@ def test_list_service_impact_snapshots_filters_by_readable_items(db): visible_snapshot = _create_snapshot( team=catalog.team, - captured_at=datetime.utcnow(), + captured_at=utc_now(), ) _create_snapshot_item( visible_snapshot, @@ -168,7 +169,7 @@ def test_list_service_impact_snapshots_filters_by_readable_items(db): hidden_snapshot = _create_snapshot( team=other_team, - captured_at=datetime.utcnow(), + captured_at=utc_now(), ) _create_snapshot_item( hidden_snapshot, @@ -187,8 +188,8 @@ def test_list_service_impact_snapshots_filters_by_readable_items(db): def test_build_service_impact_history_aggregates_snapshots_and_top_services(db): catalog = _create_cloud_catalog() - first_captured_at = datetime.utcnow() - timedelta(hours=2) - second_captured_at = datetime.utcnow() - timedelta(hours=1) + first_captured_at = utc_now() - timedelta(hours=2) + second_captured_at = utc_now() - timedelta(hours=1) first = _create_snapshot( team=catalog.team, @@ -263,7 +264,7 @@ def test_cleanup_service_impact_snapshots_deletes_old_rows(db): old_snapshot = _create_snapshot( team=catalog.team, - captured_at=datetime.utcnow() - timedelta(days=40), + captured_at=utc_now() - timedelta(days=40), ) _create_snapshot_item( old_snapshot, @@ -274,7 +275,7 @@ def test_cleanup_service_impact_snapshots_deletes_old_rows(db): fresh_snapshot = _create_snapshot( team=catalog.team, - captured_at=datetime.utcnow(), + captured_at=utc_now(), affected_services=0, ) _create_snapshot_item( diff --git a/tests/services/test_services.py b/tests/services/test_services.py index 6069914..984271c 100644 --- a/tests/services/test_services.py +++ b/tests/services/test_services.py @@ -27,6 +27,7 @@ create_impact_alert_group, create_service_dependency, ) +from app.modules.common import utc_now def service_payload(team, **overrides): @@ -787,7 +788,7 @@ def create_service_alert_group( alertname="ServiceAlert", summary="Service alert", ): - now = datetime.utcnow() + now = utc_now() labels = { "alertname": alertname, diff --git a/tests/test_escalation_policies.py b/tests/test_escalation_policies.py index bb67762..5dc20bb 100644 --- a/tests/test_escalation_policies.py +++ b/tests/test_escalation_policies.py @@ -18,6 +18,7 @@ create_user, unique, ) +from app.modules.common import utc_now def normalized_alert(**overrides): @@ -234,7 +235,7 @@ def test_policy_escalation_moves_alert_to_next_rule(monkeypatch, db): assert alert_group.escalation_rule.id == first_rule.id assert alert_group.assignee.id == first_user.id - alert_group.next_escalation_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.next_escalation_at = utc_now() - timedelta(seconds=1) alert_group.save() calls = [] @@ -303,7 +304,7 @@ def test_policy_alert_ignores_team_escalation_after_reminders(monkeypatch, db): assert created is True alert_group.reminder_count = 10 - alert_group.next_escalation_at = datetime.utcnow() + timedelta(minutes=30) + alert_group.next_escalation_at = utc_now() + timedelta(minutes=30) alert_group.save() assert maybe_escalate_alert(alert_group) is False @@ -355,7 +356,7 @@ def test_policy_escalation_runs_when_reminder_interval_is_disabled(monkeypatch, assert created is True - alert_group.next_escalation_at = datetime.utcnow() - timedelta(seconds=1) + alert_group.next_escalation_at = utc_now() - timedelta(seconds=1) alert_group.save() assert send_unacked_reminders() == 1 diff --git a/tests/test_repositories_extended.py b/tests/test_repositories_extended.py index cccd84f..f4e3aee 100644 --- a/tests/test_repositories_extended.py +++ b/tests/test_repositories_extended.py @@ -24,6 +24,7 @@ from app.modules.db import alerts_repo from tests.factories import create_group, create_route, create_service, create_team +from app.modules.common import utc_now def _create_repo_alert_group(team, route, service, title, status, severity, group_key): @@ -274,7 +275,7 @@ def test_locks_repo_acquires_rejects_busy_steals_expired_and_releases(db): assert locks_repo.acquire_lock("job", "owner-2", ttl_seconds=60) is False lock = AppLock.get(AppLock.name == "job") - lock.expires_at = datetime.utcnow() - timedelta(seconds=1) + lock.expires_at = utc_now() - timedelta(seconds=1) lock.save() assert locks_repo.acquire_lock("job", "owner-2", ttl_seconds=60) is True From aca06d25019527bfaa78b45c9a968176579b34d0 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Tue, 21 Jul 2026 14:43:01 +0300 Subject: [PATCH 04/34] Fix/2.0 schema aware migrations (#29) --- app/check_schema.py | 5 +- .../20260526000001_escalation_policies.py | 5 +- app/migrations/20260528000001_services.py | 5 +- ...000001_oncall_shift_email_notifications.py | 8 +- app/migrations/20260601999999_alert_groups.py | 5 +- ...604000001_rotation_layer_member_periods.py | 5 +- ...03_alert_group_nullable_legacy_alert_fk.py | 8 +- ...000004_alert_groups_repair_and_backfill.py | 8 +- ...00006_alert_group_notification_schedule.py | 8 +- ...20260604000008_group_user_notifications.py | 8 +- .../20260606000002_incident_management.py | 8 +- ...0606000004_fix_maintenance_window_model.py | 5 +- ...09000001_alert_route_integration_config.py | 5 +- ...000002_service_owner_notification_flags.py | 5 +- ...00001_stakeholder_comment_notifications.py | 8 +- .../20260618000001_user_timezone.py | 5 +- .../20260623000001_notification_policies.py | 5 +- .../20260623000002_priority_policies.py | 5 +- .../20260623000003_matcher_presets.py | 5 +- ...625000001_route_service_matcher_presets.py | 35 +- .../20260625000002_silence_matcher_preset.py | 18 +- ...26000001_service_runbook_matcher_preset.py | 18 +- .../20260626000002_services_v3_foundation.py | 21 +- ...all_shift_mattermost_profile_preference.py | 5 +- .../20260702000003_sso_team_mappings.py | 21 +- ...06000001_business_service_manual_status.py | 5 +- .../20260707000002_heartbeat_instances.py | 5 +- ...60720090000_event_orchestration_runtime.py | 98 ++ app/migrations/introspection.py | 108 +++ app/modules/db/migrations.py | 23 +- app/modules/db/models.py | 19 + app/modules/db/orchestrations_repo.py | 69 ++ app/services/alerts/lifecycle.py | 139 ++- .../incidents/priority_policies/resolver.py | 47 +- .../notifications/policies/resolver.py | 65 +- app/services/orchestration/__init__.py | 20 + app/services/orchestration/cache.py | 32 + app/services/orchestration/runtime.py | 895 ++++++++++++++++++ .../test_migration_introspection.py | 158 ++++ .../test_orchestration_migration.py | 39 + .../test_orchestration_runtime.py | 648 +++++++++++++ .../test_orchestration_versioning.py | 1 + 42 files changed, 2443 insertions(+), 162 deletions(-) create mode 100644 app/migrations/20260720090000_event_orchestration_runtime.py create mode 100644 app/migrations/introspection.py create mode 100644 app/services/orchestration/cache.py create mode 100644 app/services/orchestration/runtime.py create mode 100644 tests/migrations/test_migration_introspection.py create mode 100644 tests/orchestration/test_orchestration_runtime.py diff --git a/app/check_schema.py b/app/check_schema.py index e603e02..f88d73a 100644 --- a/app/check_schema.py +++ b/app/check_schema.py @@ -2,6 +2,7 @@ import sys from app.db import init_database +from app.migrations.introspection import get_columns, get_tables from app.modules.db.models import BaseModel import app.modules.db.models as models @@ -42,7 +43,7 @@ def main(): """ db = init_database() db.connect(reuse_if_open=True) - tables = set(db.get_tables()) + tables = set(get_tables(db)) ok = True for model in model_classes(): @@ -53,7 +54,7 @@ def main(): ok = False continue - db_columns = {column.name for column in db.get_columns(table)} + db_columns = {column.name for column in get_columns(db, table)} model_columns = {field.column_name for field in model._meta.sorted_fields} missing_columns = sorted(model_columns - db_columns) diff --git a/app/migrations/20260526000001_escalation_policies.py b/app/migrations/20260526000001_escalation_policies.py index 8d943bd..bfac06d 100644 --- a/app/migrations/20260526000001_escalation_policies.py +++ b/app/migrations/20260526000001_escalation_policies.py @@ -1,4 +1,7 @@ from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.models import Alert, AlertRoute, EscalationPolicy, EscalationPolicyRule @@ -6,7 +9,7 @@ def _column_exists(table_name, column_name): - columns = db.get_columns(table_name) + columns = migration_get_columns(db, table_name) return any(column.name == column_name for column in columns) diff --git a/app/migrations/20260528000001_services.py b/app/migrations/20260528000001_services.py index ed141b2..ade97a9 100644 --- a/app/migrations/20260528000001_services.py +++ b/app/migrations/20260528000001_services.py @@ -2,6 +2,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import ( Alert, @@ -25,7 +28,7 @@ def table_has_column(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260530000001_oncall_shift_email_notifications.py b/app/migrations/20260530000001_oncall_shift_email_notifications.py index f2b6d4d..c58d8f8 100644 --- a/app/migrations/20260530000001_oncall_shift_email_notifications.py +++ b/app/migrations/20260530000001_oncall_shift_email_notifications.py @@ -12,6 +12,10 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import BaseModel, Rotation, User from app.modules.common import utc_now @@ -22,11 +26,11 @@ def table_has_column(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def table_exists(table_name): - return table_name in db.get_tables() + return table_name in migration_get_tables(db) class OnCallShiftEmailNotificationMigration(BaseModel): diff --git a/app/migrations/20260601999999_alert_groups.py b/app/migrations/20260601999999_alert_groups.py index 1a962ee..2b98e80 100644 --- a/app/migrations/20260601999999_alert_groups.py +++ b/app/migrations/20260601999999_alert_groups.py @@ -3,6 +3,9 @@ from peewee import IntegerField from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import ( Alert, @@ -20,7 +23,7 @@ def _has_column(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def _create_index(table_name, index_name, columns): diff --git a/app/migrations/20260604000001_rotation_layer_member_periods.py b/app/migrations/20260604000001_rotation_layer_member_periods.py index 1a673dc..06b7bb5 100644 --- a/app/migrations/20260604000001_rotation_layer_member_periods.py +++ b/app/migrations/20260604000001_rotation_layer_member_periods.py @@ -1,6 +1,9 @@ """Convert rotation layer members to versioned membership periods.""" from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) db = init_database() @@ -12,7 +15,7 @@ def _is_postgres(): def _has_column(table_name: str, column_name: str) -> bool: - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260604000003_alert_group_nullable_legacy_alert_fk.py b/app/migrations/20260604000003_alert_group_nullable_legacy_alert_fk.py index 9b155ea..dd7c3a9 100644 --- a/app/migrations/20260604000003_alert_group_nullable_legacy_alert_fk.py +++ b/app/migrations/20260604000003_alert_group_nullable_legacy_alert_fk.py @@ -1,6 +1,10 @@ """Allow group-level alert events and notifications.""" from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) from app.modules.db.models import AlertEvent, AlertNotification @@ -17,14 +21,14 @@ def _table_name(model): def _has_table(table_name: str) -> bool: - return table_name in db.get_tables() + return table_name in migration_get_tables(db) def _has_column(table_name: str, column_name: str) -> bool: if not _has_table(table_name): return False - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def _quote(name: str) -> str: diff --git a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py index 6bae483..db6889c 100644 --- a/app/migrations/20260604000004_alert_groups_repair_and_backfill.py +++ b/app/migrations/20260604000004_alert_groups_repair_and_backfill.py @@ -7,6 +7,10 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import ( Alert, @@ -32,7 +36,7 @@ def _table(model): def _tables(): - return set(db.get_tables()) + return set(migration_get_tables(db)) def _has_table(table_name): @@ -43,7 +47,7 @@ def _has_column(table_name, column_name): if not _has_table(table_name): return False - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def _quote(name): diff --git a/app/migrations/20260604000006_alert_group_notification_schedule.py b/app/migrations/20260604000006_alert_group_notification_schedule.py index 381dba8..aae9d8f 100644 --- a/app/migrations/20260604000006_alert_group_notification_schedule.py +++ b/app/migrations/20260604000006_alert_group_notification_schedule.py @@ -1,6 +1,10 @@ """Add delayed notification scheduling to alert groups.""" from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) db = init_database() @@ -12,10 +16,10 @@ def _is_postgres(): def _has_column(table_name: str, column_name: str) -> bool: - if table_name not in db.get_tables(): + if table_name not in migration_get_tables(db): return False - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260604000008_group_user_notifications.py b/app/migrations/20260604000008_group_user_notifications.py index 365c375..e4a79b1 100644 --- a/app/migrations/20260604000008_group_user_notifications.py +++ b/app/migrations/20260604000008_group_user_notifications.py @@ -1,6 +1,10 @@ """Move user notification deliveries and browser push tokens to alert groups.""" from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) db = init_database() @@ -12,14 +16,14 @@ def _is_postgres(): def _has_table(table_name): - return table_name in db.get_tables() + return table_name in migration_get_tables(db) def _has_column(table_name, column_name): if not _has_table(table_name): return False - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260606000002_incident_management.py b/app/migrations/20260606000002_incident_management.py index aaaf487..863bb41 100644 --- a/app/migrations/20260606000002_incident_management.py +++ b/app/migrations/20260606000002_incident_management.py @@ -7,6 +7,10 @@ from datetime import datetime from app.db import database_proxy as db +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) from app.modules.db.models import ( IncidentPriority, IncidentResponder, @@ -92,14 +96,14 @@ def _execute(sql, params=None): def _table_exists(table_name): - return table_name in _database().get_tables() + return table_name in migration_get_tables(_database()) def _column_exists(table_name, column_name): if not _table_exists(table_name): return False - return any(column.name == column_name for column in _database().get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(_database(), table_name)) def _add_column_if_missing(table_name, column_name, column_definition): diff --git a/app/migrations/20260606000004_fix_maintenance_window_model.py b/app/migrations/20260606000004_fix_maintenance_window_model.py index 3378a56..284bf58 100644 --- a/app/migrations/20260606000004_fix_maintenance_window_model.py +++ b/app/migrations/20260606000004_fix_maintenance_window_model.py @@ -1,4 +1,7 @@ from app.db import database_proxy as db +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) def _database(): @@ -16,7 +19,7 @@ def _is_sqlite(): def _has_column(table_name, column_name): return any( column.name == column_name - for column in db.get_columns(table_name) + for column in migration_get_columns(db, table_name) ) diff --git a/app/migrations/20260609000001_alert_route_integration_config.py b/app/migrations/20260609000001_alert_route_integration_config.py index 3712001..ad4322a 100644 --- a/app/migrations/20260609000001_alert_route_integration_config.py +++ b/app/migrations/20260609000001_alert_route_integration_config.py @@ -2,6 +2,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import AlertRoute @@ -11,7 +14,7 @@ def table_has_column(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260615000002_service_owner_notification_flags.py b/app/migrations/20260615000002_service_owner_notification_flags.py index 9bcd87e..b6ed9a0 100644 --- a/app/migrations/20260615000002_service_owner_notification_flags.py +++ b/app/migrations/20260615000002_service_owner_notification_flags.py @@ -4,6 +4,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import ServiceOwner @@ -15,7 +18,7 @@ def table_has_column(table_name, column_name): return any( column.name == column_name - for column in db.get_columns(table_name) + for column in migration_get_columns(db, table_name) ) diff --git a/app/migrations/20260616000001_stakeholder_comment_notifications.py b/app/migrations/20260616000001_stakeholder_comment_notifications.py index a470f70..35a173a 100644 --- a/app/migrations/20260616000001_stakeholder_comment_notifications.py +++ b/app/migrations/20260616000001_stakeholder_comment_notifications.py @@ -1,4 +1,8 @@ from app.db import database_proxy as db +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) def _database(): @@ -10,11 +14,11 @@ def _quote(identifier): def _table_exists(table_name): - return table_name in _database().get_tables() + return table_name in migration_get_tables(_database()) def _column_exists(table_name, column_name): - columns = _database().get_columns(table_name) + columns = migration_get_columns(_database(), table_name) return any(column.name == column_name for column in columns) diff --git a/app/migrations/20260618000001_user_timezone.py b/app/migrations/20260618000001_user_timezone.py index 330645d..a075909 100644 --- a/app/migrations/20260618000001_user_timezone.py +++ b/app/migrations/20260618000001_user_timezone.py @@ -4,6 +4,9 @@ from peewee import CharField from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import User @@ -15,7 +18,7 @@ def table_has_column(table_name, column_name): return any( column.name == column_name - for column in db.get_columns(table_name) + for column in migration_get_columns(db, table_name) ) diff --git a/app/migrations/20260623000001_notification_policies.py b/app/migrations/20260623000001_notification_policies.py index 5f8e99e..ace4257 100644 --- a/app/migrations/20260623000001_notification_policies.py +++ b/app/migrations/20260623000001_notification_policies.py @@ -2,6 +2,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import ( AlertRoute, @@ -19,7 +22,7 @@ def table_has_column(table_name, column_name): return any( column.name == column_name - for column in db.get_columns(table_name) + for column in migration_get_columns(db, table_name) ) diff --git a/app/migrations/20260623000002_priority_policies.py b/app/migrations/20260623000002_priority_policies.py index d942fbb..a6d55c3 100644 --- a/app/migrations/20260623000002_priority_policies.py +++ b/app/migrations/20260623000002_priority_policies.py @@ -1,4 +1,7 @@ from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.models import MatcherPreset, PriorityPolicy, PriorityPolicyRule @@ -8,7 +11,7 @@ def _column_exists(table_name, column_name): return any( column.name == column_name - for column in db.get_columns(table_name) + for column in migration_get_columns(db, table_name) ) diff --git a/app/migrations/20260623000003_matcher_presets.py b/app/migrations/20260623000003_matcher_presets.py index fa8fc45..fe9122c 100644 --- a/app/migrations/20260623000003_matcher_presets.py +++ b/app/migrations/20260623000003_matcher_presets.py @@ -1,4 +1,7 @@ from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.models import MatcherPreset @@ -6,7 +9,7 @@ def _column_exists(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def _is_postgres(): diff --git a/app/migrations/20260625000001_route_service_matcher_presets.py b/app/migrations/20260625000001_route_service_matcher_presets.py index f57844e..d1284cd 100644 --- a/app/migrations/20260625000001_route_service_matcher_presets.py +++ b/app/migrations/20260625000001_route_service_matcher_presets.py @@ -1,6 +1,10 @@ import re from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_tables as migration_get_tables, +) db = init_database() @@ -15,25 +19,8 @@ def _is_mysql(): return "mysql" in db.__class__.__name__.lower() -def _current_schema(): - if not _is_postgres(): - return None - - row = db.execute_sql("SELECT current_schema()").fetchone() - - if not row or not row[0]: - raise RuntimeError("PostgreSQL current schema could not be determined") - - return row[0] - - def _get_tables(): - schema = _current_schema() - - if schema: - return db.get_tables(schema=schema) - - return db.get_tables() + return migration_get_tables(db) def _quote_identifier(value): @@ -45,14 +32,10 @@ def _quote_identifier(value): def _table_columns(table_name): - schema = _current_schema() - - if schema: - columns = db.get_columns(table_name, schema=schema) - else: - columns = db.get_columns(table_name) - - return {column.name for column in columns} + return { + column.name + for column in migration_get_columns(db, table_name) + } def _find_table(label, preferred_names, required_columns): diff --git a/app/migrations/20260625000002_silence_matcher_preset.py b/app/migrations/20260625000002_silence_matcher_preset.py index bbf54aa..559b6fb 100644 --- a/app/migrations/20260625000002_silence_matcher_preset.py +++ b/app/migrations/20260625000002_silence_matcher_preset.py @@ -1,6 +1,9 @@ import re from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.models import MatcherPreset, Silence @@ -24,21 +27,8 @@ def _quote_identifier(value): return f"{quote}{value}{quote}" -def _current_schema(): - if not _is_postgres(): - return None - - row = db.execute_sql("SELECT current_schema()").fetchone() - return row[0] if row else None - - def _get_columns(table_name): - schema = _current_schema() - - if schema: - return db.get_columns(table_name, schema=schema) - - return db.get_columns(table_name) + return migration_get_columns(db, table_name) def _column_exists(table_name, column_name): diff --git a/app/migrations/20260626000001_service_runbook_matcher_preset.py b/app/migrations/20260626000001_service_runbook_matcher_preset.py index 54142d5..8e5f022 100644 --- a/app/migrations/20260626000001_service_runbook_matcher_preset.py +++ b/app/migrations/20260626000001_service_runbook_matcher_preset.py @@ -1,6 +1,9 @@ import re from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.models import MatcherPreset, ServiceRunbook @@ -24,21 +27,8 @@ def _quote_identifier(value): return f"{quote}{value}{quote}" -def _current_schema(): - if not _is_postgres(): - return None - - row = db.execute_sql("SELECT current_schema()").fetchone() - return row[0] if row else None - - def _get_columns(table_name): - schema = _current_schema() - - if schema: - return db.get_columns(table_name, schema=schema) - - return db.get_columns(table_name) + return migration_get_columns(db, table_name) def _column_exists(table_name, column_name): diff --git a/app/migrations/20260626000002_services_v3_foundation.py b/app/migrations/20260626000002_services_v3_foundation.py index 1f8c4ec..a4c482e 100644 --- a/app/migrations/20260626000002_services_v3_foundation.py +++ b/app/migrations/20260626000002_services_v3_foundation.py @@ -5,6 +5,10 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_indexes as migration_get_indexes, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import Service, ServiceDependency, ServiceEvent @@ -28,25 +32,12 @@ def _quote_identifier(value): return f"{quote}{value}{quote}" -def _current_schema(): - if not _is_postgres(): - return None - row = db.execute_sql("SELECT current_schema()").fetchone() - return row[0] if row else None - - def _get_columns(table_name): - schema = _current_schema() - if schema: - return db.get_columns(table_name, schema=schema) - return db.get_columns(table_name) + return migration_get_columns(db, table_name) def _get_indexes(table_name): - schema = _current_schema() - if schema: - return db.get_indexes(table_name, schema=schema) - return db.get_indexes(table_name) + return migration_get_indexes(db, table_name) def _column_exists(table_name, column_name): diff --git a/app/migrations/20260702000002_oncall_shift_mattermost_profile_preference.py b/app/migrations/20260702000002_oncall_shift_mattermost_profile_preference.py index a239d1d..ccabc2f 100644 --- a/app/migrations/20260702000002_oncall_shift_mattermost_profile_preference.py +++ b/app/migrations/20260702000002_oncall_shift_mattermost_profile_preference.py @@ -4,6 +4,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import User @@ -14,7 +17,7 @@ def table_has_column(table_name, column_name): """Return True if table already has column.""" - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260702000003_sso_team_mappings.py b/app/migrations/20260702000003_sso_team_mappings.py index 6414fc6..508ca27 100644 --- a/app/migrations/20260702000003_sso_team_mappings.py +++ b/app/migrations/20260702000003_sso_team_mappings.py @@ -6,6 +6,10 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, + get_indexes as migration_get_indexes, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import SsoGroupMapping @@ -30,25 +34,12 @@ def _quote_identifier(value): return f"{quote}{value}{quote}" -def _current_schema(): - if not _is_postgres(): - return None - row = db.execute_sql("SELECT current_schema()").fetchone() - return row[0] if row else None - - def _get_columns(table_name): - schema = _current_schema() - if schema: - return db.get_columns(table_name, schema=schema) - return db.get_columns(table_name) + return migration_get_columns(db, table_name) def _get_indexes(table_name): - schema = _current_schema() - if schema: - return db.get_indexes(table_name, schema=schema) - return db.get_indexes(table_name) + return migration_get_indexes(db, table_name) def _column_exists(table_name, column_name): diff --git a/app/migrations/20260706000001_business_service_manual_status.py b/app/migrations/20260706000001_business_service_manual_status.py index 14ceb4e..13fccff 100644 --- a/app/migrations/20260706000001_business_service_manual_status.py +++ b/app/migrations/20260706000001_business_service_manual_status.py @@ -4,13 +4,16 @@ from playhouse.migrate import SchemaMigrator, migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) db = init_database() def _column_names(table_name): - return {column.name for column in db.get_columns(table_name)} + return {column.name for column in migration_get_columns(db, table_name)} def _has_column(table_name, column_name): diff --git a/app/migrations/20260707000002_heartbeat_instances.py b/app/migrations/20260707000002_heartbeat_instances.py index 92cfaf2..a91fec0 100644 --- a/app/migrations/20260707000002_heartbeat_instances.py +++ b/app/migrations/20260707000002_heartbeat_instances.py @@ -4,6 +4,9 @@ from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import ( + get_columns as migration_get_columns, +) from app.modules.db.migrator import get_migrator from app.modules.db.models import Heartbeat, HeartbeatInstance, HeartbeatPing @@ -13,7 +16,7 @@ def table_has_column(table_name, column_name): - return any(column.name == column_name for column in db.get_columns(table_name)) + return any(column.name == column_name for column in migration_get_columns(db, table_name)) def upgrade(): diff --git a/app/migrations/20260720090000_event_orchestration_runtime.py b/app/migrations/20260720090000_event_orchestration_runtime.py new file mode 100644 index 0000000..68bad1b --- /dev/null +++ b/app/migrations/20260720090000_event_orchestration_runtime.py @@ -0,0 +1,98 @@ +"""Add runtime rollout state and persisted notification-policy overrides.""" + +from peewee import CharField, IntegerField, SqliteDatabase +from playhouse.migrate import SchemaMigrator, migrate + +from app.db import init_database + + +db = init_database() +RUNTIME_COLUMNS = { + "event_orchestration": { + "compatibility_mode": lambda: CharField( + max_length=32, + default="legacy", + ), + }, + "alert_group": { + "notification_policy_id": lambda: IntegerField(null=True), + }, + "alert": { + "notification_policy_id": lambda: IntegerField(null=True), + }, +} +RUNTIME_INDEXES = ( + ("event_orchestration", ("group_id", "compatibility_mode", "enabled")), + ("alert_group", ("notification_policy_id",)), + ("alert", ("notification_policy_id",)), +) + + +def _table_exists(table): + return table in db.get_tables() + + +def _columns(table): + if not _table_exists(table): + return set() + return {column.name for column in db.get_columns(table)} + + +def _indexes(table): + if not _table_exists(table): + return [] + return list(db.get_indexes(table)) + + +def _has_index(table, columns): + expected = tuple(columns) + return any(tuple(index.columns) == expected for index in _indexes(table)) + + +def upgrade(): + migrator = SchemaMigrator.from_database(db) + operations = [] + + for table, columns in RUNTIME_COLUMNS.items(): + if not _table_exists(table): + continue + existing = _columns(table) + for name, field_factory in columns.items(): + if name not in existing: + operations.append( + migrator.add_column(table, name, field_factory()) + ) + + if operations: + migrate(*operations) + + for table, columns in RUNTIME_INDEXES: + if _table_exists(table) and not _has_index(table, columns): + migrate(migrator.add_index(table, columns, unique=False)) + + +def downgrade(): + migrator = SchemaMigrator.from_database(db) + + for table, columns in reversed(tuple(RUNTIME_COLUMNS.items())): + if not _table_exists(table): + continue + removed_columns = set(columns) + for index in _indexes(table): + if removed_columns.intersection(index.columns): + migrate(migrator.drop_index(table, index.name)) + + for table, columns in reversed(tuple(RUNTIME_COLUMNS.items())): + existing = _columns(table) + for name in reversed(tuple(columns)): + if name not in existing: + continue + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column( + table, + name, + legacy=True, + ) + else: + operation = migrator.drop_column(table, name) + migrate(operation) diff --git a/app/migrations/introspection.py b/app/migrations/introspection.py new file mode 100644 index 0000000..2e073c8 --- /dev/null +++ b/app/migrations/introspection.py @@ -0,0 +1,108 @@ +"""Schema-aware database introspection helpers for migrations. + +Peewee's PostgreSQL introspection methods default to the ``public`` schema +when no schema is provided. IncidentRelay supports custom ``search_path`` +configurations, so migrations must resolve the schema of the unqualified +relation exactly as PostgreSQL would before checking columns or indexes. +""" + +from typing import Any, List, Optional + + +def _database(database): + """Unwrap a DatabaseProxy while accepting a regular database object.""" + return getattr(database, "obj", database) + + +def is_postgres(database) -> bool: + name = _database(database).__class__.__name__.lower() + return "postgres" in name or "postgre" in name or "cockroach" in name + + +def resolve_table_schema(database, table_name: str) -> Optional[str]: + """Return the schema PostgreSQL resolves for an unqualified table name.""" + db = _database(database) + + if not is_postgres(db): + return None + + row = db.execute_sql( + """ + SELECT namespace.nspname + FROM pg_catalog.pg_class AS relation + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = relation.relnamespace + WHERE relation.oid = pg_catalog.to_regclass(%s) + """, + (table_name,), + ).fetchone() + + return row[0] if row and row[0] else None + + +def get_columns(database, table_name: str) -> List[Any]: + """Return columns for the relation selected by the active search_path.""" + db = _database(database) + schema = resolve_table_schema(db, table_name) + + if is_postgres(db): + if schema is None: + return [] + return db.get_columns(table_name, schema=schema) + + return db.get_columns(table_name) + + +def get_indexes(database, table_name: str) -> List[Any]: + """Return indexes for the relation selected by the active search_path.""" + db = _database(database) + schema = resolve_table_schema(db, table_name) + + if is_postgres(db): + if schema is None: + return [] + return db.get_indexes(table_name, schema=schema) + + return db.get_indexes(table_name) + + +def get_tables(database) -> List[str]: + """Return table names visible through the active PostgreSQL search_path.""" + db = _database(database) + + if not is_postgres(db): + return db.get_tables() + + rows = db.execute_sql( + """ + SELECT DISTINCT table_name + FROM information_schema.tables + WHERE table_schema = ANY (pg_catalog.current_schemas(FALSE)) + AND table_type IN ('BASE TABLE', 'LOCAL TEMPORARY') + ORDER BY table_name + """ + ).fetchall() + + return [row[0] for row in rows] + + +def table_exists(database, table_name: str) -> bool: + """Return whether an unqualified relation resolves on the active search_path.""" + db = _database(database) + + if is_postgres(db): + row = db.execute_sql( + "SELECT pg_catalog.to_regclass(%s) IS NOT NULL", + (table_name,), + ).fetchone() + return bool(row and row[0]) + + return table_name in db.get_tables() + + +def column_exists(database, table_name: str, column_name: str) -> bool: + return any(column.name == column_name for column in get_columns(database, table_name)) + + +def index_exists(database, table_name: str, index_name: str) -> bool: + return any(index.name == index_name for index in get_indexes(database, table_name)) diff --git a/app/modules/db/migrations.py b/app/modules/db/migrations.py index b79b91b..da71ec7 100644 --- a/app/modules/db/migrations.py +++ b/app/modules/db/migrations.py @@ -83,6 +83,7 @@ def create_migration(name: str) -> str: from playhouse.migrate import migrate from app.db import init_database +from app.migrations.introspection import column_exists, index_exists, table_exists from app.modules.db.migrator import get_migrator @@ -145,8 +146,17 @@ def apply_migration(filepath: str) -> None: try: print(f"Applying migration {migration_name}...") - upgrade_func() - Migration.create(name=migration_name) + + db = init_database() + + # PostgreSQL DDL is transactional. Keep the schema change and the + # migration record in the same transaction so a process restart cannot + # leave an applied migration recorded as pending. On backends that + # auto-commit DDL, migration helpers still need to remain idempotent. + with db.atomic(): + upgrade_func() + Migration.create(name=migration_name) + print(f"Migration {migration_name} applied successfully") except Exception as exc: raise MigrationError(f"Failed to apply migration {migration_name}: {exc}") from exc @@ -175,8 +185,13 @@ def rollback_migration(migration_name: str) -> None: try: print(f"Rolling back migration {migration_name}...") - downgrade_func() - migration_record.delete_instance() + + db = init_database() + + with db.atomic(): + downgrade_func() + migration_record.delete_instance() + print(f"Migration {migration_name} rolled back successfully") except Exception as exc: raise MigrationError(f"Failed to rollback migration {migration_name}: {exc}") from exc diff --git a/app/modules/db/models.py b/app/modules/db/models.py index d870024..6d2ee0b 100644 --- a/app/modules/db/models.py +++ b/app/modules/db/models.py @@ -1717,6 +1717,13 @@ class AlertGroup(BaseModel): backref="alert_groups", on_delete="SET NULL", ) + notification_policy = ForeignKeyField( + NotificationPolicy, + null=True, + backref="alert_groups", + on_delete="SET NULL", + index=True, + ) next_escalation_at = DateTimeField(null=True, index=True) last_escalated_at = DateTimeField(null=True) @@ -1850,6 +1857,13 @@ class Alert(BaseModel): backref="alerts", on_delete="SET NULL", ) + notification_policy = ForeignKeyField( + NotificationPolicy, + null=True, + backref="alerts", + on_delete="SET NULL", + index=True, + ) next_escalation_at = DateTimeField(null=True, index=True) last_escalated_at = DateTimeField(null=True) escalation_repeat_count = IntegerField(default=0) @@ -2721,6 +2735,7 @@ class CalendarFeed(SoftDeleteModel): EVENT_ORCHESTRATION_SCOPES = ("global", "service") EVENT_ORCHESTRATION_MODES = ("active", "shadow", "disabled") +EVENT_ORCHESTRATION_COMPATIBILITY_MODES = ("legacy", "hybrid", "orchestration") EVENT_ORCHESTRATION_VERSION_STATUSES = ("draft", "published", "archived") EVENT_ORCHESTRATION_PROCESSING_MODES = ( "continue", @@ -2753,6 +2768,7 @@ class EventOrchestration(SoftDeleteModel): ) enabled = BooleanField(default=False, index=True) mode = CharField(max_length=32, default="disabled", index=True) + compatibility_mode = CharField(max_length=32, default="legacy", index=True) # Kept as an integer to avoid a circular DDL dependency between the # orchestration and orchestration-version tables. Repository code verifies # that the selected version belongs to this orchestration. @@ -2772,6 +2788,7 @@ class Meta: (("group", "name"), True), (("group", "scope"), False), (("group", "mode", "enabled"), False), + (("group", "compatibility_mode", "enabled"), False), (("service", "enabled"), False), ) @@ -2780,6 +2797,8 @@ def save(self, *args, **kwargs): raise ValueError("Invalid orchestration scope") if self.mode not in EVENT_ORCHESTRATION_MODES: raise ValueError("Invalid orchestration mode") + if self.compatibility_mode not in EVENT_ORCHESTRATION_COMPATIBILITY_MODES: + raise ValueError("Invalid orchestration compatibility mode") if self.scope == "global" and self.service_id is not None: raise ValueError("Global orchestration cannot reference a service") diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index 1daf8a6..539c3af 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -25,6 +25,7 @@ VALID_SCOPES = {"global", "service"} VALID_MODES = {"active", "shadow", "disabled"} +VALID_COMPATIBILITY_MODES = {"legacy", "hybrid", "orchestration"} VALID_VERSION_STATUSES = {"draft", "published", "archived"} VALID_PROCESSING_MODES = { "continue", @@ -173,12 +174,15 @@ def create_orchestration( service_id: Optional[int] = None, description: Optional[str] = None, created_by_id: Optional[int] = None, + compatibility_mode: str = "legacy", ) -> EventOrchestration: name = (name or "").strip() if not name: raise OrchestrationValidationError(["name is required"]) if scope not in VALID_SCOPES: raise OrchestrationValidationError(["scope must be global or service"]) + if compatibility_mode not in VALID_COMPATIBILITY_MODES: + raise OrchestrationValidationError(["invalid compatibility_mode"]) if scope == "global" and service_id is not None: raise OrchestrationValidationError( ["global orchestration cannot reference a service"] @@ -202,6 +206,7 @@ def create_orchestration( service=service_id, enabled=False, mode="disabled", + compatibility_mode=compatibility_mode, created_by=created_by_id, ) except IntegrityError as exc: @@ -465,6 +470,8 @@ def validate_version(version_id: int) -> Dict[str, Any]: errors.append("invalid orchestration scope") if orchestration.mode not in VALID_MODES: errors.append("invalid orchestration mode") + if orchestration.compatibility_mode not in VALID_COMPATIBILITY_MODES: + errors.append("invalid orchestration compatibility mode") if orchestration.scope == "global" and orchestration.service_id is not None: errors.append("global orchestration cannot reference a service") @@ -678,6 +685,68 @@ def archive_draft(version_id: int) -> EventOrchestrationVersion: return _get_version(version_id) +def set_runtime_state( + orchestration_id: int, + *, + enabled: bool, + mode: str, + compatibility_mode: str, +) -> EventOrchestration: + """Enable or disable one orchestration runtime atomically.""" + if mode not in VALID_MODES: + raise OrchestrationValidationError(["invalid orchestration mode"]) + if compatibility_mode not in VALID_COMPATIBILITY_MODES: + raise OrchestrationValidationError(["invalid compatibility_mode"]) + if bool(enabled) != (mode != "disabled"): + raise OrchestrationValidationError([ + "enabled must be true for active/shadow mode and false for disabled mode" + ]) + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + if enabled and mode != "disabled" and orchestration.active_version_id is None: + raise OrchestrationConflict("Published version is required before enabling runtime") + EventOrchestration.update( + enabled=bool(enabled), + mode=mode, + compatibility_mode=compatibility_mode, + updated_at=_utcnow(), + ).where(EventOrchestration.id == orchestration.id).execute() + return _get_orchestration(orchestration_id) + + +def list_runtime_orchestrations( + *, + group_id: int, + scope: str, + service_id: Optional[int] = None, +) -> List[EventOrchestration]: + """Return enabled runtime orchestrations in deterministic order.""" + if scope not in VALID_SCOPES: + raise OrchestrationValidationError(["scope must be global or service"]) + query = EventOrchestration.select().where( + (EventOrchestration.group == group_id) + & (EventOrchestration.scope == scope) + & (EventOrchestration.enabled == True) # noqa: E712 + & (EventOrchestration.mode.in_(("active", "shadow"))) + & EventOrchestration.active_version_id.is_null(False) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if scope == "service": + query = query.where(EventOrchestration.service == service_id) + return list(query.order_by(EventOrchestration.id.asc())) + + +def get_published_runtime_version(orchestration: EventOrchestration) -> EventOrchestrationVersion: + """Return and verify the exact published version selected for runtime.""" + if orchestration.active_version_id is None: + raise OrchestrationConflict("Orchestration has no active version") + version = _get_version(orchestration.active_version_id) + if version.orchestration_id != orchestration.id or version.status != "published": + raise OrchestrationConflict("Active orchestration version is not published") + return version + + def archive_orchestration(orchestration_id: int) -> EventOrchestration: with database_proxy.atomic(): orchestration = _locked_orchestration(orchestration_id) diff --git a/app/services/alerts/lifecycle.py b/app/services/alerts/lifecycle.py index 9060acb..73690f7 100644 --- a/app/services/alerts/lifecycle.py +++ b/app/services/alerts/lifecycle.py @@ -21,6 +21,11 @@ ) from app.services.incidents.priority_policies.resolver import resolve_incident_priority from app.services.alerts.result import AlertProcessingResult +from app.services.orchestration.runtime import ( + attach_runtime_executions, + run_event_orchestration, + run_service_orchestration, +) from app.services.incidents.stakeholders import notify_stakeholders from app.services.maintenance import get_maintenance_decision from app.services.notifications.delivery import notify_alert @@ -39,6 +44,22 @@ logger = logging.getLogger("oncall.alerts") +def _route_for_runtime(alert_data, runtime, *, current_route=None): + if runtime is None: + return find_route_for_alert(alert_data) + + if runtime.route_selected_by_orchestration: + return runtime.route + + if runtime.route is None: + return find_route_for_alert(alert_data) + + if current_route is None: + return find_route_for_alert(alert_data) + + return current_route + + def _add_service_stakeholders_for_new_group(group): """Auto-add service stakeholders to a newly created incident.""" try: @@ -66,9 +87,20 @@ def upsert_alert(alert_data): AlertProcessingResult """ trace = AlertExplainTrace.start(alert_data) + runtime = None try: - return _upsert_alert(alert_data, trace) + runtime = run_event_orchestration(alert_data, trace=trace) + if runtime.blocked: + return _stopped_result( + trace=trace, + outcome="orchestration_blocked", + reason=runtime.reason or "Event orchestration blocked processing.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + result = _upsert_alert(alert_data, trace, runtime=runtime) + attach_runtime_executions(runtime, group=result.group, alert=result.alert) + return result except Exception as exc: trace.fail(exc) raise @@ -103,8 +135,10 @@ def _completed_result(*, trace, group, alert, created_group, outcome): ) -def _resolve_policy_assignment(route, service, rotation, maintenance_decision, trace): - policy = get_effective_escalation_policy(route, service) +def _resolve_policy_assignment( + route, service, rotation, maintenance_decision, trace, *, policy_override=None +): + policy = policy_override or get_effective_escalation_policy(route, service) policy_rule, rotation, assignee, next_escalation_at = ( apply_initial_escalation_policy_assignment(policy, rotation) @@ -132,6 +166,7 @@ def _create_group( rotation, policy, policy_rule, + notification_policy, assignee, next_escalation_at, group_key, @@ -149,6 +184,9 @@ def _create_group( rotation=rotation.id if rotation else None, escalation_policy=policy.id if policy else None, escalation_rule=policy_rule.id if policy_rule else None, + notification_policy=( + notification_policy.id if notification_policy else None + ), next_escalation_at=next_escalation_at, assignee=assignee.id if assignee else None, source=alert_data["source"], @@ -173,12 +211,16 @@ def _set_alert_routing_fields( route, service, rotation, + notification_policy, group, ): alert.team = team.id if team else None alert.route = route.id if route else None alert.service = service.id if service else None alert.rotation = rotation.id if rotation else None + alert.notification_policy = ( + notification_policy.id if notification_policy else None + ) alert.group = group.id @@ -199,6 +241,8 @@ def _handle_existing_alert( maintenance_decision, maintenance_kwargs, now, + policy_override=None, + notification_policy_override=None, ): group = existing_alert.group or existing_group priority = priority_resolution.priority @@ -213,6 +257,7 @@ def _handle_existing_alert( rotation, maintenance_decision, trace, + policy_override=policy_override, ) ) @@ -224,6 +269,7 @@ def _handle_existing_alert( rotation=rotation, policy=policy, policy_rule=policy_rule, + notification_policy=notification_policy_override, assignee=assignee, next_escalation_at=next_escalation_at, group_key=group_key, @@ -279,9 +325,15 @@ def _handle_existing_alert( route=route, service=service, rotation=rotation, + notification_policy=notification_policy_override, group=group, ) + group.notification_policy = ( + notification_policy_override.id if notification_policy_override else None + ) + group.save(only=[group.__class__.notification_policy]) + if maintenance_decision.pause_escalation_only: existing_alert.next_escalation_at = None @@ -405,8 +457,8 @@ def _handle_existing_alert( ) -def _upsert_alert(alert_data, trace): - route = find_route_for_alert(alert_data) +def _upsert_alert(alert_data, trace, runtime=None): + route = _route_for_runtime(alert_data, runtime) if not route: trace.route_not_matched(alert_data) @@ -436,7 +488,52 @@ def _upsert_alert(alert_data, trace): ) team = route.team - service = resolve_alert_service(route, alert_data) + service = ( + runtime.service + if runtime and runtime.service + else resolve_alert_service(route, alert_data) + ) + + if runtime is not None and service is not None: + runtime = run_service_orchestration( + alert_data, + runtime, + route=route, + team=team, + service=service, + trace=trace, + ) + + if runtime.blocked: + return _stopped_result( + trace=trace, + outcome="orchestration_blocked", + reason=runtime.reason or "Service orchestration blocked processing.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + + route = _route_for_runtime( + alert_data, + runtime, + current_route=route, + ) + + if not route: + trace.route_not_matched(alert_data) + + return _stopped_result( + trace=trace, + outcome="routing_failed", + reason="Service orchestration did not leave an active route.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + team = route.team + service = ( + runtime.service + if runtime.service + else resolve_alert_service(route, alert_data) + ) + rotation = get_effective_route_rotation(route, service) trace.route_matched(route, team) @@ -459,10 +556,10 @@ def _upsert_alert(alert_data, trace): severity=alert_data.get("severity"), ) - group_key = build_group_key( - route, - alert_data, - service=service, + group_key = ( + runtime.group_key + if runtime and runtime.group_key + else build_group_key(route, alert_data, service=service) ) trace.group_key_built(group_key) @@ -484,10 +581,15 @@ def _upsert_alert(alert_data, trace): trace.maintenance_resolved(maintenance_decision) + grouping_window_seconds = ( + runtime.grouping_window_seconds + if runtime and runtime.grouping_window_seconds is not None + else Config.ALERT_GROUP_WINDOW_SECONDS + ) existing_alert = alerts_repo.find_existing_alert( alert_data["source"], alert_data["dedup_key"], - Config.ALERT_GROUP_WINDOW_SECONDS, + grouping_window_seconds, ) existing_group = alerts_repo.find_open_alert_group( @@ -539,6 +641,10 @@ def _upsert_alert(alert_data, trace): maintenance_decision=maintenance_decision, maintenance_kwargs=maintenance_kwargs, now=now, + policy_override=(runtime.escalation_policy if runtime else None), + notification_policy_override=( + runtime.notification_policy if runtime else None + ), ) if status == "resolved": @@ -574,6 +680,7 @@ def _upsert_alert(alert_data, trace): rotation, maintenance_decision, trace, + policy_override=(runtime.escalation_policy if runtime else None), ) ) @@ -599,6 +706,7 @@ def _upsert_alert(alert_data, trace): rotation=rotation, policy=policy, policy_rule=policy_rule, + notification_policy=(runtime.notification_policy if runtime else None), assignee=assignee, next_escalation_at=next_escalation_at, group_key=group_key, @@ -643,6 +751,10 @@ def _upsert_alert(alert_data, trace): else: trace.group_reused(group) + if runtime is not None and runtime.notification_policy is not None: + group.notification_policy = runtime.notification_policy.id + group.save(only=[group.__class__.notification_policy]) + priority_state_before_recalculate = group_priority_state(group) previous_priority_slug = ( @@ -666,6 +778,11 @@ def _upsert_alert(alert_data, trace): rotation=rotation.id if rotation else None, escalation_policy=policy.id if policy else None, escalation_rule=policy_rule.id if policy_rule else None, + notification_policy=( + runtime.notification_policy.id + if runtime and runtime.notification_policy + else getattr(group, "notification_policy_id", None) + ), next_escalation_at=next_escalation_at, assignee=assignee.id if assignee else None, source=alert_data["source"], diff --git a/app/services/incidents/priority_policies/resolver.py b/app/services/incidents/priority_policies/resolver.py index ccaf270..85c358e 100644 --- a/app/services/incidents/priority_policies/resolver.py +++ b/app/services/incidents/priority_policies/resolver.py @@ -126,40 +126,53 @@ def resolve_incident_priority( team_id = team.id if team else None explicit_source_value = _source_priority_value(alert_data) - if alert_data.get("priority_set_manually") and explicit_source_value: + explicit_priority_requested = ( + alert_data.get("priority_set_manually") + or alert_data.get("priority_set_by_orchestration") + ) + if explicit_priority_requested and explicit_source_value: explicit_priority = _priority_from_source_value(explicit_source_value) if explicit_priority: return PriorityResolution( priority=explicit_priority, - source="explicit_source_priority", + source=( + "orchestration" + if alert_data.get("priority_set_by_orchestration") + else "explicit_source_priority" + ), source_priority_value=explicit_source_value, ) - policy = policy_service.get_effective_policy( - team_id=team_id, - service=service, - ) + orchestration_policy_id = alert_data.get("orchestration_priority_policy_id") + if orchestration_policy_id: + policy = priority_policies_repo.get_priority_policy_or_none( + orchestration_policy_id + ) + if policy and (not policy.enabled or policy.team_id != team_id): + policy = None + else: + policy = policy_service.get_effective_policy( + team_id=team_id, + service=service, + ) if not policy: - update_mode = ( - getattr(policy, "update_mode", None) - or "raise_only" - ) return PriorityResolution( priority=_severity_fallback(alert_data), source="severity_mapping", - update_mode=update_mode, + update_mode="raise_only", ) - policy_source = "team_default" + policy_source = "orchestration" if orchestration_policy_id else "team_default" - if service and getattr(service, "priority_policy_id", None) == policy.id: + if ( + not orchestration_policy_id + and service + and getattr(service, "priority_policy_id", None) == policy.id + ): policy_source = "service" - update_mode = ( - getattr(policy, "update_mode", None) - or "raise_only" - ) + update_mode = getattr(policy, "update_mode", None) or "raise_only" result = PriorityResolution( priority=None, diff --git a/app/services/notifications/policies/resolver.py b/app/services/notifications/policies/resolver.py index cecd17a..422f265 100644 --- a/app/services/notifications/policies/resolver.py +++ b/app/services/notifications/policies/resolver.py @@ -71,34 +71,56 @@ def _add_route_channels(result, route): _add_channel(result, link.channel, "route") -def _add_service_policy_channels(result, group, event_type): - service = getattr(group, "service", None) - +def _selected_notification_policy(result, subject): + override_id = getattr(subject, "notification_policy_id", None) + if override_id: + policy = notification_policies_repo.get_notification_policy_or_none( + override_id, + include_deleted=True, + ) + result.policy_id = override_id + if not policy: + result.notes.append("orchestration_notification_policy_missing") + return None, "orchestration_policy" + if policy.deleted: + result.notes.append("orchestration_notification_policy_deleted") + return None, "orchestration_policy" + if not policy.enabled: + result.notes.append("orchestration_notification_policy_disabled") + return None, "orchestration_policy" + if policy.team_id != getattr(subject, "team_id", None): + result.notes.append("orchestration_notification_policy_team_mismatch") + return None, "orchestration_policy" + return policy, "orchestration_policy" + + service = getattr(subject, "service", None) if not service: result.notes.append("service_missing") - return + return None, "service_policy" result.service_id = service.id - policy_id = getattr(service, "notification_policy_id", None) - if not policy_id: result.notes.append("service_notification_policy_missing") - return + return None, "service_policy" policy = service.notification_policy result.policy_id = policy.id - if policy.deleted: result.notes.append("service_notification_policy_deleted") - return - + return None, "service_policy" if not policy.enabled: result.notes.append("service_notification_policy_disabled") - return - - if policy.team_id != getattr(group, "team_id", None): + return None, "service_policy" + if policy.team_id != getattr(subject, "team_id", None): result.notes.append("service_notification_policy_team_mismatch") + return None, "service_policy" + return policy, "service_policy" + + +def _add_service_policy_channels(result, group, event_type): + policy, source = _selected_notification_policy(result, group) + if policy is None: return matched = False @@ -129,7 +151,7 @@ def _add_service_policy_channels(result, group, event_type): ) for channel in channels: - _add_channel(result, channel, "service_policy", rule.id) + _add_channel(result, channel, source, rule.id) if not rule.continue_matching: break @@ -160,15 +182,26 @@ def resolve_notification_channels(group, event_type="notification"): getattr(route, "notification_channel_mode", None) or ROUTE_ONLY ) + has_orchestration_policy = bool( + getattr(group, "notification_policy_id", None) + ) - if configured_mode in NOTIFICATION_CHANNEL_MODES: + if has_orchestration_policy: + mode = ( + SERVICE_POLICY_PLUS_ROUTE + if configured_mode == SERVICE_POLICY_PLUS_ROUTE + else SERVICE_POLICY + ) + elif configured_mode in NOTIFICATION_CHANNEL_MODES: mode = configured_mode else: mode = ROUTE_ONLY result = NotificationChannelResolution(mode=mode) - if configured_mode != mode: + if has_orchestration_policy: + result.notes.append("orchestration_notification_policy_override") + elif configured_mode != mode: result.notes.append("unknown_channel_mode_fallback_to_route_only") if mode in {SERVICE_POLICY, SERVICE_POLICY_PLUS_ROUTE}: diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py index 68050e9..d35669d 100644 --- a/app/services/orchestration/__init__.py +++ b/app/services/orchestration/__init__.py @@ -52,3 +52,23 @@ "validate_action_list", ] # END EVENT ORCHESTRATION WS3 EXPORTS + +# BEGIN EVENT ORCHESTRATION WS4 EXPORTS +from .runtime import ( + RuntimeOrchestrationError, + RuntimeResult, + RuntimeStep, + attach_runtime_executions, + run_event_orchestration, + run_service_orchestration, +) + +__all__ += [ + "RuntimeOrchestrationError", + "RuntimeResult", + "RuntimeStep", + "attach_runtime_executions", + "run_event_orchestration", + "run_service_orchestration", +] +# END EVENT ORCHESTRATION WS4 EXPORTS diff --git a/app/services/orchestration/cache.py b/app/services/orchestration/cache.py new file mode 100644 index 0000000..4751062 --- /dev/null +++ b/app/services/orchestration/cache.py @@ -0,0 +1,32 @@ +"""Small process-local cache for immutable published definitions.""" + +from collections import OrderedDict +from copy import deepcopy +from threading import RLock + + +class PublishedDefinitionCache: + def __init__(self, max_entries=256): + self.max_entries = max(1, int(max_entries)) + self._items = OrderedDict() + self._lock = RLock() + + def get(self, version): + key = (int(version.id), str(version.definition_hash or "")) + with self._lock: + value = self._items.get(key) + if value is None: + value = deepcopy(version.definition_json or {}) + self._items[key] = value + while len(self._items) > self.max_entries: + self._items.popitem(last=False) + else: + self._items.move_to_end(key) + return deepcopy(value) + + def clear(self): + with self._lock: + self._items.clear() + + +published_definition_cache = PublishedDefinitionCache() diff --git a/app/services/orchestration/runtime.py b/app/services/orchestration/runtime.py new file mode 100644 index 0000000..1ab8e02 --- /dev/null +++ b/app/services/orchestration/runtime.py @@ -0,0 +1,895 @@ +"""Runtime handoff from published orchestration definitions to alert lifecycle.""" + +from __future__ import annotations + +import copy +import logging +import time +from dataclasses import dataclass, field, replace +from typing import Any, Dict, List, Mapping, Optional + +from app.modules.common import utc_now +from app.modules.db import orchestrations_repo +from app.modules.db.models import ( + AlertRoute, + EscalationPolicy, + EventOrchestration, + NotificationPolicy, + OrchestrationExecution, + PriorityPolicy, + Service, + Team, +) +from app.services.orchestration.cache import published_definition_cache +from app.services.orchestration.engine import execute_rule_tree +from app.services.orchestration.fields import build_context +from app.modules.redaction import redact_secrets + +logger = logging.getLogger("oncall.orchestration.runtime") + +_COMPATIBILITY_ORDER = {"legacy": 0, "hybrid": 1, "orchestration": 2} +_UNSUPPORTED_RUNTIME_DISPOSITIONS = {"suppress", "pause", "drop"} + + +class RuntimeOrchestrationError(RuntimeError): + pass + + +@dataclass(frozen=True) +class RuntimeStep: + orchestration_id: int + version_id: int + scope: str + mode: str + compatibility_mode: str + applied: bool + execution_id: Optional[int] + duration_ms: int + matched_rule_count: int = 0 + outcome: str = "continue" + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "orchestration_id": self.orchestration_id, + "version_id": self.version_id, + "scope": self.scope, + "mode": self.mode, + "compatibility_mode": self.compatibility_mode, + "applied": self.applied, + "execution_id": self.execution_id, + "duration_ms": self.duration_ms, + "matched_rule_count": self.matched_rule_count, + "outcome": self.outcome, + "error": self.error, + } + + +@dataclass +class RuntimeResult: + group_id: Optional[int] = None + compatibility_mode: str = "legacy" + route: Any = None + team: Any = None + service: Any = None + escalation_policy: Any = None + priority_policy: Any = None + notification_policy: Any = None + group_key: Optional[str] = None + grouping_window_seconds: Optional[int] = None + route_selected_by_orchestration: bool = False + steps: List[RuntimeStep] = field(default_factory=list) + blocked: bool = False + reason: Optional[str] = None + deferred_disposition: Optional[str] = None + evaluated_service_ids: List[int] = field(default_factory=list, repr=False) + _context: Dict[str, Any] = field(default_factory=dict, repr=False) + + @property + def execution_ids(self): + return [step.execution_id for step in self.steps if step.execution_id] + + def to_dict(self): + return { + "group_id": self.group_id, + "compatibility_mode": self.compatibility_mode, + "route_id": getattr(self.route, "id", None), + "team_id": getattr(self.team, "id", None), + "service_id": getattr(self.service, "id", None), + "escalation_policy_id": getattr(self.escalation_policy, "id", None), + "priority_policy_id": getattr(self.priority_policy, "id", None), + "notification_policy_id": getattr(self.notification_policy, "id", None), + "group_key": self.group_key, + "grouping_window_seconds": self.grouping_window_seconds, + "blocked": self.blocked, + "reason": self.reason, + "deferred_disposition": self.deferred_disposition, + "steps": [step.to_dict() for step in self.steps], + } + + +def _bounded_trace_value(value: Any, *, depth: int = 0) -> Any: + """Return a bounded, JSON-compatible value for execution traces.""" + if depth > 12: + return "" + + if isinstance(value, str): + return value if len(value) <= 2048 else value[:2048] + "…" + + if value is None or isinstance(value, (int, float, bool)): + return value + + if isinstance(value, Mapping): + return { + str(key): _bounded_trace_value(item, depth=depth + 1) + for key, item in list(value.items())[:512] + } + + if isinstance(value, (list, tuple, set)): + return [ + _bounded_trace_value(item, depth=depth + 1) + for item in list(value)[:512] + ] + + if isinstance(value, BaseException): + return str(value) + + return str(value) + + +def _safe_trace_value(value: Any) -> Any: + """Bound trace data and redact secrets using the project-wide helper.""" + return redact_secrets(_bounded_trace_value(value)) + + +def _entity_context(entity) -> Dict[str, Any]: + if entity is None: + return {} + result = {"id": getattr(entity, "id", None)} + for key in ("name", "slug", "source", "enabled", "active"): + value = getattr(entity, key, None) + if value is not None: + result[key] = value + return result + + +def _route_group_id(route) -> Optional[int]: + team = getattr(route, "team", None) + group_id = getattr(team, "group_id", None) + return int(group_id) if group_id is not None else None + + +def _group_id_from_alert(alert_data: Mapping[str, Any]) -> Optional[int]: + explicit = alert_data.get("orchestration_group_id") or alert_data.get("group_id") + if explicit not in (None, ""): + try: + return int(explicit) + except (TypeError, ValueError): + return None + + route_id = alert_data.get("forced_route_id") + if route_id: + route = AlertRoute.get_or_none(AlertRoute.id == route_id) + return _route_group_id(route) if route else None + + team_id = alert_data.get("forced_team_id") + if team_id: + team = Team.get_or_none(Team.id == team_id) + return int(team.group_id) if team and team.group_id is not None else None + + team_slug = alert_data.get("team_slug") + if team_slug: + team = Team.get_or_none(Team.slug == team_slug) + return int(team.group_id) if team and team.group_id is not None else None + + return None + + +def _load_route(route_id: Any, *, group_id: int, source: Optional[str]): + if route_id in (None, ""): + return None + route = AlertRoute.get_or_none(AlertRoute.id == int(route_id)) + if not route or not route.enabled or getattr(route, "deleted", False): + raise RuntimeOrchestrationError("selected route is missing or disabled") + if _route_group_id(route) != int(group_id): + raise RuntimeOrchestrationError("selected route belongs to another group") + if source and route.source != source: + raise RuntimeOrchestrationError("selected route source does not match event source") + if ( + not route.team + or not route.team.active + or not route.team.group + or not route.team.group.active + ): + raise RuntimeOrchestrationError("selected route team or group is inactive") + return route + + +def _load_team(team_id: Any, *, group_id: int): + if team_id in (None, ""): + return None + team = Team.get_or_none(Team.id == int(team_id)) + if not team or not team.active or getattr(team, "deleted", False): + raise RuntimeOrchestrationError("selected team is missing or inactive") + if int(team.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected team belongs to another group") + return team + + +def _load_service(service_id: Any, *, group_id: int): + if service_id in (None, ""): + return None + service = Service.get_or_none(Service.id == int(service_id)) + if not service or not service.enabled or getattr(service, "deleted", False): + raise RuntimeOrchestrationError("selected service is missing or disabled") + if int(service.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected service belongs to another group") + return service + + +def _load_policy(model, policy_id, *, group_id: int, team_id: Optional[int]): + if policy_id in (None, ""): + return None + policy = model.get_or_none(model.id == int(policy_id)) + if ( + not policy + or not getattr(policy, "enabled", False) + or getattr(policy, "deleted", False) + ): + raise RuntimeOrchestrationError("selected policy is missing or disabled") + policy_team = getattr(policy, "team", None) + if not policy_team or int(policy_team.group_id or 0) != int(group_id): + raise RuntimeOrchestrationError("selected policy belongs to another group") + if team_id is not None and int(policy.team_id) != int(team_id): + raise RuntimeOrchestrationError("selected policy belongs to another team") + return policy + + +def _initial_entities(alert_data, group_id): + route = _load_route( + alert_data.get("forced_route_id"), + group_id=group_id, + source=alert_data.get("source"), + ) if alert_data.get("forced_route_id") else None + team = ( + route.team + if route + else _load_team( + alert_data.get("forced_team_id"), + group_id=group_id, + ) + ) + service = ( + _load_service(alert_data.get("service_id"), group_id=group_id) + if alert_data.get("service_id") + else (route.service if route and getattr(route, "service_id", None) else None) + ) + return route, team, service + + +def _build_runtime_context(alert_data, *, route=None, team=None, service=None): + event = { + str(key): copy.deepcopy(value) + for key, value in alert_data.items() + if key not in {"payload", "raw"} and not str(key).startswith("_orchestration") + } + return build_context( + event=event, + labels=event.get("labels") or {}, + raw=copy.deepcopy(alert_data.get("raw") or alert_data.get("payload") or {}), + route=_entity_context(route), + team=_entity_context(team), + service=_entity_context(service), + integration={ + "name": alert_data.get("source"), + "source": alert_data.get("source"), + }, + time={"now": utc_now().isoformat()}, + ) + + +def _record_execution( + *, orchestration, version, alert_data, result=None, duration_ms=0, applied=False, + error=None, initial_context=None, +): + trace = { + "applied": bool(applied), + "mode": orchestration.mode, + "compatibility_mode": orchestration.compatibility_mode, + "initial_context": _safe_trace_value(initial_context or {}), + "result": _safe_trace_value(result.to_dict()) if result else None, + "error": _safe_trace_value(error), + } + disposition = None + matched = 0 + if result is not None: + disposition = result.context.get("result", {}).get("disposition") + matched = result.matched_rule_count + row = OrchestrationExecution.create( + group=orchestration.group_id, + orchestration=orchestration.id, + version=version.id, + source=alert_data.get("source"), + integration_name=alert_data.get("integration_name") or alert_data.get("source"), + event_fingerprint=( + alert_data.get("dedup_key") + or alert_data.get("external_id") + ), + disposition=disposition, + matched_rule_count=matched, + duration_ms=duration_ms, + trace_json=trace, + ) + return row.id + + +def _safe_record_execution(**kwargs): + try: + return _record_execution(**kwargs) + except Exception: + logger.exception( + "failed to persist orchestration execution", + extra={"extra": { + "orchestration_id": getattr(kwargs.get("orchestration"), "id", None), + "version_id": getattr(kwargs.get("version"), "id", None), + }}, + ) + return None + + +def _run_one(orchestration: EventOrchestration, context, alert_data): + started = time.perf_counter() + initial_context = copy.deepcopy(context) + try: + version = orchestrations_repo.get_published_runtime_version(orchestration) + definition = published_definition_cache.get(version) + rules = definition.get("rules") or [] + result = execute_rule_tree(rules, context) + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + applied = ( + orchestration.mode == "active" + and orchestration.compatibility_mode != "legacy" + ) + execution_id = _safe_record_execution( + orchestration=orchestration, + version=version, + alert_data=alert_data, + result=result, + duration_ms=duration_ms, + applied=applied, + initial_context=initial_context, + ) + return result, RuntimeStep( + orchestration_id=orchestration.id, + version_id=version.id, + scope=orchestration.scope, + mode=orchestration.mode, + compatibility_mode=orchestration.compatibility_mode, + applied=applied, + execution_id=execution_id, + duration_ms=duration_ms, + matched_rule_count=result.matched_rule_count, + outcome=result.outcome, + ) + except Exception as exc: + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + safe_error = exc.__class__.__name__ + version = locals().get("version") + execution_id = None + if version is not None: + execution_id = _safe_record_execution( + orchestration=orchestration, + version=version, + alert_data=alert_data, + duration_ms=duration_ms, + applied=False, + error=safe_error, + initial_context=initial_context, + ) + return None, RuntimeStep( + orchestration_id=orchestration.id, + version_id=getattr( + version, + "id", + orchestration.active_version_id or 0, + ), + scope=orchestration.scope, + mode=orchestration.mode, + compatibility_mode=orchestration.compatibility_mode, + applied=False, + execution_id=execution_id, + duration_ms=duration_ms, + error=safe_error, + outcome="failed", + ) + + +def _mark_execution_rejected(step: RuntimeStep, reason: str) -> RuntimeStep: + """Correct the audit row when a candidate fails runtime entity validation.""" + if step.execution_id: + try: + execution = OrchestrationExecution.get_by_id(step.execution_id) + trace_json = copy.deepcopy(execution.trace_json or {}) + trace_json["applied"] = False + trace_json["rejected_reason"] = _safe_trace_value(reason) + execution.trace_json = trace_json + execution.save(only=[OrchestrationExecution.trace_json]) + except Exception: + logger.exception( + "failed to mark orchestration execution as rejected", + extra={"extra": {"execution_id": step.execution_id}}, + ) + return replace( + step, + applied=False, + outcome="rejected", + error="RuntimeOrchestrationError", + ) + + +def _apply_candidate(candidate): + return copy.deepcopy(candidate.context) + + +def _selected_ids(context): + result = context.get("result") or {} + routing = result.get("routing") or {} + policies = result.get("policies") or {} + return routing, policies, result.get("grouping") or {} + + +def _resolve_selected_entities( + context, *, group_id, source, fallback_route=None, fallback_team=None, + fallback_service=None, fallback_escalation=None, fallback_priority=None, + fallback_notification=None, +): + routing, policies, grouping = _selected_ids(context) + explicit_route = routing.get("route_id") not in (None, "") + explicit_team = routing.get("team_id") not in (None, "") + explicit_service = routing.get("service_id") not in (None, "") + + if explicit_route: + route = _load_route(routing.get("route_id"), group_id=group_id, source=source) + elif explicit_team: + route = None + else: + route = fallback_route + + if explicit_team: + team = _load_team(routing.get("team_id"), group_id=group_id) + elif route: + team = route.team + else: + team = fallback_team + + if explicit_service: + service = _load_service(routing.get("service_id"), group_id=group_id) + if not explicit_team and not explicit_route: + team = service.team + if route and route.team_id != team.id: + route = None + elif fallback_service is not None: + service = fallback_service + elif route and getattr(route, "service_id", None): + service = route.service + else: + service = None + + if route and team and route.team_id != team.id: + raise RuntimeOrchestrationError("selected route and team do not match") + if service and team and service.team_id != team.id: + raise RuntimeOrchestrationError("selected service and team do not match") + + team_id = getattr(team, "id", None) + escalation = ( + _load_policy( + EscalationPolicy, + policies.get("escalation_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("escalation_policy_id") else fallback_escalation + ) + priority = ( + _load_policy( + PriorityPolicy, + policies.get("priority_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("priority_policy_id") else fallback_priority + ) + notification = ( + _load_policy( + NotificationPolicy, + policies.get("notification_policy_id"), + group_id=group_id, + team_id=team_id, + ) + if policies.get("notification_policy_id") else fallback_notification + ) + return route, team, service, escalation, priority, notification, grouping + + +def _refresh_entity_context(context, *, route, team, service): + context["route"] = _entity_context(route) + context["team"] = _entity_context(team) + context["service"] = _entity_context(service) + + +def _evaluate_orchestrations( + orchestrations, + *, + context, + alert_data, + runtime, + group_id, + route, + team, + service, +): + for orchestration in orchestrations: + # Legacy mode exists only as a safe rollout default. It must not execute + # or write shadow audit rows unless the operator explicitly switches it. + if orchestration.mode == "active" and orchestration.compatibility_mode == "legacy": + continue + + candidate, step = _run_one(orchestration, context, alert_data) + if step.error: + runtime.steps.append(step) + if ( + orchestration.mode == "active" + and orchestration.compatibility_mode == "orchestration" + ): + runtime.blocked = True + runtime.reason = "Active orchestration evaluation failed" + break + continue + + if not step.applied: + runtime.steps.append(step) + continue + + candidate_context = _apply_candidate(candidate) + try: + ( + route_candidate, + team_candidate, + service_candidate, + escalation, + priority, + notification, + grouping, + ) = _resolve_selected_entities( + candidate_context, + group_id=group_id, + source=alert_data.get("source"), + fallback_route=route, + fallback_team=team, + fallback_service=service, + fallback_escalation=runtime.escalation_policy, + fallback_priority=runtime.priority_policy, + fallback_notification=runtime.notification_policy, + ) + except RuntimeOrchestrationError as exc: + rejected_step = _mark_execution_rejected(step, str(exc)) + runtime.steps.append(rejected_step) + if orchestration.compatibility_mode == "orchestration": + runtime.blocked = True + runtime.reason = str(exc) + break + logger.warning( + "hybrid orchestration result rejected", + extra={"extra": { + "orchestration_id": orchestration.id, + "error": str(exc), + }}, + ) + continue + + runtime.steps.append(step) + runtime.compatibility_mode = max( + runtime.compatibility_mode, + orchestration.compatibility_mode, + key=lambda value: _COMPATIBILITY_ORDER[value], + ) + context = candidate_context + route, team, service = route_candidate, team_candidate, service_candidate + _refresh_entity_context(context, route=route, team=team, service=service) + runtime.route = route + runtime.team = team + runtime.service = service + runtime.escalation_policy = escalation + runtime.priority_policy = priority + runtime.notification_policy = notification + routing_result = (context.get("result") or {}).get("routing") or {} + + runtime.route_selected_by_orchestration = ( + runtime.route_selected_by_orchestration + or routing_result.get("route_id") not in (None, "") + ) + + event_group_key = (context.get("event") or {}).get("group_key") + if grouping.get("group_key") not in (None, ""): + runtime.group_key = grouping.get("group_key") + elif event_group_key not in (None, ""): + runtime.group_key = event_group_key + if "window_seconds" in grouping: + runtime.grouping_window_seconds = grouping.get("window_seconds") + + disposition = (context.get("result") or {}).get("disposition") + if disposition in _UNSUPPORTED_RUNTIME_DISPOSITIONS: + runtime.deferred_disposition = disposition + + runtime._context = copy.deepcopy(context) + return context, route, team, service + + +_MUTABLE_EVENT_FIELDS = { + "title", + "message", + "description", + "severity", + "priority", + "dedup_key", + "group_key", + "labels", + "custom_details", + "event_action", +} + + +def _apply_runtime_to_alert_data( + runtime: RuntimeResult, + alert_data: Dict[str, Any], +): + if runtime.blocked or not any(step.applied for step in runtime.steps): + return + + event = (runtime._context or {}).get("event") or {} + for key in _MUTABLE_EVENT_FIELDS: + if key in event: + alert_data[key] = copy.deepcopy(event[key]) + + if event.get("event_action") == "resolve": + alert_data["status"] = "resolved" + elif event.get("event_action") == "trigger": + alert_data["status"] = "firing" + + if runtime.route is not None: + alert_data["forced_route_id"] = runtime.route.id + alert_data["forced_team_id"] = runtime.route.team_id + elif runtime.team is not None: + alert_data.pop("forced_route_id", None) + alert_data["forced_team_id"] = runtime.team.id + if runtime.service is not None: + alert_data["service_id"] = runtime.service.id + if runtime.group_key: + alert_data["orchestration_group_key"] = runtime.group_key + if runtime.priority_policy is not None: + alert_data["orchestration_priority_policy_id"] = runtime.priority_policy.id + if runtime.escalation_policy is not None: + alert_data["orchestration_escalation_policy_id"] = runtime.escalation_policy.id + if runtime.notification_policy is not None: + alert_data["orchestration_notification_policy_id"] = runtime.notification_policy.id + if event.get("priority") not in (None, ""): + alert_data["priority"] = event["priority"] + alert_data["priority_set_by_orchestration"] = True + + +def _trace_runtime(trace, result: RuntimeResult, *, phase="global"): + if trace is None: + return + status = ( + "error" + if result.blocked + else ("success" if result.steps else "skipped") + ) + trace.step( + "orchestration", + "orchestration_runtime_blocked" if result.blocked else "orchestration_runtime_evaluated", + status, + ( + "Event orchestration evaluated" + if not result.blocked + else "Event orchestration blocked processing" + ), + result.reason, + phase=phase, + orchestration=result.to_dict(), + ) + + +def _evaluate_service_chain( + alert_data, + runtime, + *, + context, + route, + team, + service, +): + """Run each selected service orchestration once, including service handoffs.""" + current_service = service + iterations = 0 + while current_service is not None and not runtime.blocked: + service_id = int(current_service.id) + if service_id in runtime.evaluated_service_ids: + break + if iterations >= 8: + runtime.blocked = runtime.compatibility_mode == "orchestration" + runtime.reason = "Service orchestration handoff limit exceeded" + break + iterations += 1 + runtime.evaluated_service_ids.append(service_id) + orchestrations = orchestrations_repo.list_runtime_orchestrations( + group_id=runtime.group_id, + scope="service", + service_id=service_id, + ) + context, route, team, selected_service = _evaluate_orchestrations( + orchestrations, + context=context, + alert_data=alert_data, + runtime=runtime, + group_id=runtime.group_id, + route=route, + team=team, + service=current_service, + ) + current_service = selected_service + return context, route, team, current_service + + +def run_event_orchestration(alert_data: Dict[str, Any], *, trace=None) -> RuntimeResult: + """Evaluate published global orchestration before legacy lifecycle routing.""" + runtime = RuntimeResult() + group_id = _group_id_from_alert(alert_data) + runtime.group_id = group_id + if group_id is None: + _trace_runtime(trace, runtime) + return runtime + + global_orchestrations = orchestrations_repo.list_runtime_orchestrations( + group_id=group_id, + scope="global", + ) + try: + route, team, service = _initial_entities(alert_data, group_id) + except RuntimeOrchestrationError as exc: + requires_orchestration = any( + item.mode == "active" and item.compatibility_mode == "orchestration" + for item in global_orchestrations + ) + if requires_orchestration: + runtime.blocked = True + runtime.reason = str(exc) + _trace_runtime(trace, runtime) + return runtime + + runtime.route = route + runtime.team = team + runtime.service = service + if not global_orchestrations and service is None: + _trace_runtime(trace, runtime) + return runtime + + context = _build_runtime_context(alert_data, route=route, team=team, service=service) + context, route, team, service = _evaluate_orchestrations( + global_orchestrations, + context=context, + alert_data=alert_data, + runtime=runtime, + group_id=group_id, + route=route, + team=team, + service=service, + ) + + if not runtime.blocked and service is not None: + context, route, team, service = _evaluate_service_chain( + alert_data, + runtime, + context=context, + route=route, + team=team, + service=service, + ) + + runtime._context = copy.deepcopy(context) + runtime.route = route + runtime.team = team + runtime.service = service + + if ( + not runtime.blocked + and runtime.compatibility_mode == "orchestration" + and runtime.route is None + ): + runtime.blocked = True + runtime.reason = "Orchestration mode requires a selected route" + + _apply_runtime_to_alert_data(runtime, alert_data) + _trace_runtime(trace, runtime) + return runtime + + +def run_service_orchestration( + alert_data: Dict[str, Any], + runtime: Optional[RuntimeResult], + *, + route, + team, + service, + trace=None, +) -> RuntimeResult: + """Run service-scoped orchestration after the lifecycle selects a service.""" + runtime = runtime or RuntimeResult(group_id=_route_group_id(route)) + if runtime.blocked or service is None or runtime.group_id is None: + return runtime + + previous_step_count = len(runtime.steps) + context = ( + copy.deepcopy(runtime._context) + if runtime._context + else _build_runtime_context( + alert_data, + route=route, + team=team, + service=service, + ) + ) + _refresh_entity_context(context, route=route, team=team, service=service) + runtime.route = route + runtime.team = team + runtime.service = service + + context, route, team, service = _evaluate_service_chain( + alert_data, + runtime, + context=context, + route=route, + team=team, + service=service, + ) + runtime._context = copy.deepcopy(context) + runtime.route = route + runtime.team = team + runtime.service = service + + if ( + not runtime.blocked + and runtime.compatibility_mode == "orchestration" + and runtime.route is None + ): + runtime.blocked = True + runtime.reason = "Orchestration mode requires a selected route" + + _apply_runtime_to_alert_data(runtime, alert_data) + if runtime.blocked or len(runtime.steps) != previous_step_count: + _trace_runtime(trace, runtime, phase="service") + return runtime + + +def attach_runtime_executions( + runtime: Optional[RuntimeResult], + *, + group=None, + alert=None, +): + if runtime is None or not runtime.execution_ids: + return + OrchestrationExecution.update( + alert_group_id=getattr(group, "id", None), + alert_id=getattr(alert, "id", None), + ).where(OrchestrationExecution.id.in_(runtime.execution_ids)).execute() + + +__all__ = [ + "RuntimeOrchestrationError", + "RuntimeResult", + "RuntimeStep", + "attach_runtime_executions", + "run_event_orchestration", + "run_service_orchestration", +] diff --git a/tests/migrations/test_migration_introspection.py b/tests/migrations/test_migration_introspection.py new file mode 100644 index 0000000..048b368 --- /dev/null +++ b/tests/migrations/test_migration_introspection.py @@ -0,0 +1,158 @@ +from pathlib import Path + +from app.migrations.introspection import ( + column_exists, + get_columns, + get_indexes, + get_tables, + resolve_table_schema, + table_exists, +) + + +class _Cursor: + def __init__(self, *, one=None, all_rows=None): + self._one = one + self._all_rows = all_rows or [] + + def fetchone(self): + return self._one + + def fetchall(self): + return self._all_rows + + +class _Column: + def __init__(self, name): + self.name = name + + +class _Index: + def __init__(self, name): + self.name = name + + +class FakePostgresqlDatabase: + def __init__(self, *, schema="incidentrelay", relation_exists=True): + self.schema = schema + self.relation_exists = relation_exists + self.sql_calls = [] + self.column_calls = [] + self.index_calls = [] + + def execute_sql(self, sql, params=None): + normalized = " ".join(sql.split()) + self.sql_calls.append((normalized, params)) + + if "JOIN pg_catalog.pg_namespace" in normalized: + row = (self.schema,) if self.relation_exists else None + return _Cursor(one=row) + + if "to_regclass(%s) IS NOT NULL" in normalized: + return _Cursor(one=(self.relation_exists,)) + + if "FROM information_schema.tables" in normalized: + return _Cursor(all_rows=[("alert",), ("alertroute",)]) + + raise AssertionError(f"Unexpected SQL: {normalized}") + + def get_columns(self, table_name, schema=None): + self.column_calls.append((table_name, schema)) + return [_Column("id"), _Column("escalation_policy_id")] + + def get_indexes(self, table_name, schema=None): + self.index_calls.append((table_name, schema)) + return [_Index("idx_alertroute_escalation_policy_id")] + + +class _DatabaseProxy: + def __init__(self, database): + self.obj = database + + +class FakeSqliteDatabase: + def __init__(self): + self.column_calls = [] + self.index_calls = [] + + def get_columns(self, table_name): + self.column_calls.append(table_name) + return [_Column("id")] + + def get_indexes(self, table_name): + self.index_calls.append(table_name) + return [_Index("idx_alert_id")] + + def get_tables(self): + return ["alert"] + + +def test_postgres_columns_use_schema_of_relation_resolved_by_search_path(): + db = FakePostgresqlDatabase(schema="incidentrelay") + + columns = get_columns(db, "alertroute") + + assert [column.name for column in columns] == ["id", "escalation_policy_id"] + assert db.column_calls == [("alertroute", "incidentrelay")] + assert db.sql_calls[0][1] == ("alertroute",) + + +def test_postgres_missing_relation_returns_no_columns_without_public_fallback(): + db = FakePostgresqlDatabase(relation_exists=False) + + assert get_columns(db, "missing_table") == [] + assert db.column_calls == [] + + +def test_postgres_indexes_use_resolved_relation_schema(): + db = FakePostgresqlDatabase(schema="incidentrelay") + + indexes = get_indexes(db, "alertroute") + + assert [index.name for index in indexes] == [ + "idx_alertroute_escalation_policy_id" + ] + assert db.index_calls == [("alertroute", "incidentrelay")] + + +def test_postgres_table_helpers_follow_search_path(): + db = FakePostgresqlDatabase(schema="incidentrelay") + + assert resolve_table_schema(db, "alertroute") == "incidentrelay" + assert table_exists(db, "alertroute") is True + assert get_tables(db) == ["alert", "alertroute"] + assert column_exists(db, "alertroute", "escalation_policy_id") is True + + +def test_database_proxy_is_unwrapped_before_postgres_introspection(): + database = FakePostgresqlDatabase(schema="incidentrelay") + proxy = _DatabaseProxy(database) + + assert column_exists(proxy, "alertroute", "escalation_policy_id") is True + assert database.column_calls == [("alertroute", "incidentrelay")] + + +def test_non_postgres_introspection_preserves_existing_behavior(): + db = FakeSqliteDatabase() + + assert [column.name for column in get_columns(db, "alert")] == ["id"] + assert [index.name for index in get_indexes(db, "alert")] == ["idx_alert_id"] + assert get_tables(db) == ["alert"] + assert table_exists(db, "alert") is True + assert db.column_calls == ["alert"] + assert db.index_calls == ["alert"] + + +def test_migrations_do_not_call_schema_unsafe_peewee_introspection_directly(): + migrations_dir = Path(__file__).resolve().parents[2] / "app" / "migrations" + unsafe_calls = [] + + for path in sorted(migrations_dir.glob("[0-9]*.py")): + source = path.read_text(encoding="utf-8") + + for method in ("get_columns", "get_indexes", "get_tables"): + marker = f".{method}(" + if marker in source: + unsafe_calls.append(f"{path.name}: {marker}") + + assert unsafe_calls == [] diff --git a/tests/orchestration/test_orchestration_migration.py b/tests/orchestration/test_orchestration_migration.py index ddc6570..3da82b4 100644 --- a/tests/orchestration/test_orchestration_migration.py +++ b/tests/orchestration/test_orchestration_migration.py @@ -26,3 +26,42 @@ def test_event_orchestration_migration_upgrade_and_downgrade(db): assert TABLES.isdisjoint(set(db.get_tables())) upgrade() + + + +def test_event_orchestration_runtime_migration_upgrade_and_downgrade(db): + models_path = os.path.join( + get_migrations_dir(), + "20260719090000_event_orchestration_models.py", + ) + models_upgrade, _ = load_migration_module(models_path) + models_upgrade() + + path = os.path.join( + get_migrations_dir(), + "20260720090000_event_orchestration_runtime.py", + ) + upgrade, downgrade = load_migration_module(path) + + upgrade() + expected_columns = { + "event_orchestration": "compatibility_mode", + "alert_group": "notification_policy_id", + "alert": "notification_policy_id", + } + for table, column_name in expected_columns.items(): + assert column_name in { + column.name for column in db.get_columns(table) + } + + downgrade() + for table, column_name in expected_columns.items(): + assert column_name not in { + column.name for column in db.get_columns(table) + } + + upgrade() + for table, column_name in expected_columns.items(): + assert column_name in { + column.name for column in db.get_columns(table) + } diff --git a/tests/orchestration/test_orchestration_runtime.py b/tests/orchestration/test_orchestration_runtime.py new file mode 100644 index 0000000..70fc101 --- /dev/null +++ b/tests/orchestration/test_orchestration_runtime.py @@ -0,0 +1,648 @@ +import copy + +import pytest + +from app.modules.db import alerts_repo, incidents_repo +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + ServiceMatchRule, +) +from app.modules.db.orchestrations_repo import ( + OrchestrationConflict, + OrchestrationValidationError, + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, + set_runtime_state, +) +from app.services.orchestration.cache import PublishedDefinitionCache +from app.services.orchestration.runtime import ( + RuntimeResult, + attach_runtime_executions, + run_event_orchestration, + run_service_orchestration, +) +from app.services.alerts.lifecycle import upsert_alert +from app.services.notifications.policies.resolver import resolve_notification_channels +from app.services.routing.service_resolution import resolve_alert_service +from tests.factories import ( + create_channel, + create_escalation_policy, + create_escalation_policy_rule, + create_group, + create_notification_policy, + create_notification_policy_rule, + create_priority_policy, + create_priority_policy_rule, + create_route, + create_service, + create_team, + create_user, +) + + +@pytest.fixture(autouse=True) +def orchestration_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + ], + safe=True, + ) + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _publish(group, rules, *, scope="global", service=None, mode="active", compatibility="hybrid"): + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name=f"{scope}-{service.id if service else 'default'}", + scope=scope, + service_id=service.id if service else None, + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(draft.id, rules) + publish_draft(orchestration.id, actor_id=user.id) + return set_runtime_state( + orchestration.id, + enabled=True, + mode=mode, + compatibility_mode=compatibility, + ) + + +def _always(actions): + return [{ + "name": "always", + "condition_tree": {}, + "actions": actions, + "processing_mode": "continue", + }] + + +def test_hybrid_runtime_applies_event_routing_service_and_grouping(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + service = create_service(team, name="Database", slug="database") + _publish(group, _always([ + {"type": "set_title", "value": "Orchestrated title"}, + {"type": "set_route", "route_id": route.id}, + {"type": "set_service", "service_id": service.id}, + {"type": "set_grouping", "group_key": "database-prod", "window_seconds": 120}, + ])) + + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "db-1", + "title": "Original", + "labels": {"environment": "prod"}, + "payload": {}, + } + + result = run_event_orchestration(alert_data) + + assert result.blocked is False + assert result.compatibility_mode == "hybrid" + assert result.route.id == route.id + assert result.service.id == service.id + assert result.group_key == "database-prod" + assert result.grouping_window_seconds == 120 + assert alert_data["title"] == "Orchestrated title" + assert alert_data["service_id"] == service.id + assert alert_data["orchestration_group_key"] == "database-prod" + execution = OrchestrationExecution.get_by_id(result.execution_ids[0]) + assert execution.matched_rule_count == 1 + assert execution.trace_json["applied"] is True + + +def test_shadow_runtime_records_candidate_without_mutating_event(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish( + group, + _always([{"type": "set_title", "value": "Shadow title"}]), + mode="shadow", + compatibility="hybrid", + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "shadow-1", + "title": "Original", + "labels": {}, + "payload": {}, + } + original = copy.deepcopy(alert_data) + + result = run_event_orchestration(alert_data) + + assert alert_data == original + assert result.steps[0].applied is False + execution = OrchestrationExecution.get_by_id(result.execution_ids[0]) + assert execution.trace_json["result"]["context"]["event"]["title"] == "Shadow title" + + +def test_service_orchestration_runs_after_global_service_selection(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + service = create_service(team, name="API", slug="api") + _publish(group, _always([{"type": "set_service", "service_id": service.id}])) + _publish( + group, + _always([{"type": "set_severity", "value": "critical"}]), + scope="service", + service=service, + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "api-1", + "title": "API", + "severity": "warning", + "labels": {}, + "payload": {}, + } + + result = run_event_orchestration(alert_data) + + assert len(result.steps) == 2 + assert [step.scope for step in result.steps] == ["global", "service"] + assert alert_data["severity"] == "critical" + + +def test_orchestration_mode_blocks_when_no_route_is_available(db): + group = create_group() + _publish( + group, + _always([{"type": "set_title", "value": "No route"}]), + compatibility="orchestration", + ) + alert_data = { + "source": "alertmanager", + "orchestration_group_id": group.id, + "dedup_key": "unrouted-1", + "title": "Original", + "labels": {}, + "payload": {}, + } + + result = run_event_orchestration(alert_data) + + assert result.blocked is True + assert result.reason == "Orchestration mode requires a selected route" + + +def test_legacy_compatibility_mode_does_not_mutate_event(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish( + group, + _always([{"type": "set_title", "value": "Must not apply"}]), + compatibility="legacy", + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "legacy-1", + "title": "Original", + "labels": {}, + "payload": {}, + } + + result = run_event_orchestration(alert_data) + + assert result.compatibility_mode == "legacy" + assert alert_data["title"] == "Original" + assert result.steps == [] + + +def test_attach_runtime_executions_sets_alert_and_group_ids(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, _always([])) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "attach-1", + "title": "Attach", + "labels": {}, + "payload": {}, + } + runtime = run_event_orchestration(alert_data) + row = OrchestrationExecution.get_by_id(runtime.execution_ids[0]) + group_obj = type("GroupRef", (), {"id": 101})() + alert_obj = type("AlertRef", (), {"id": 202})() + + attach_runtime_executions(runtime, group=group_obj, alert=alert_obj) + + row = OrchestrationExecution.get_by_id(row.id) + assert row.alert_group_id == 101 + assert row.alert_id == 202 + + +def test_published_definition_cache_returns_isolated_copies(): + version = type( + "Version", + (), + {"id": 7, "definition_hash": "abc", "definition_json": {"rules": [{"name": "one"}]}}, + )() + cache = PublishedDefinitionCache(max_entries=2) + + first = cache.get(version) + first["rules"][0]["name"] = "changed" + second = cache.get(version) + + assert second["rules"][0]["name"] == "one" + + + +def test_service_orchestration_runs_after_legacy_service_match(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + service = create_service(team, name="Matched service", slug="matched-service") + ServiceMatchRule.create( + team=team, + route=route, + service=service, + name="Match service label", + position=1, + enabled=True, + matchers={"labels": {"service": "matched-service"}}, + ) + _publish( + group, + _always([{"type": "set_severity", "value": "critical"}]), + scope="service", + service=service, + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "legacy-service-1", + "title": "Matched service", + "severity": "warning", + "labels": {"service": "matched-service"}, + "payload": {}, + } + + runtime = run_event_orchestration(alert_data) + assert runtime.steps == [] + selected_service = resolve_alert_service(route, alert_data) + + runtime = run_service_orchestration( + alert_data, + runtime, + route=route, + team=team, + service=selected_service, + ) + + assert selected_service.id == service.id + assert [step.scope for step in runtime.steps] == ["service"] + assert alert_data["severity"] == "critical" + + +def test_hybrid_rejected_candidate_is_not_recorded_as_applied(db): + group = create_group() + team = create_team(group) + fallback_route = create_route(team, source="alertmanager") + disabled_route = create_route(team, source="alertmanager") + _publish(group, _always([{"type": "set_route", "route_id": disabled_route.id}])) + disabled_route.enabled = False + disabled_route.save() + alert_data = { + "source": "alertmanager", + "forced_route_id": fallback_route.id, + "dedup_key": "rejected-route-1", + "title": "Fallback", + "labels": {}, + "payload": {}, + } + + runtime = run_event_orchestration(alert_data) + + assert runtime.blocked is False + assert runtime.route.id == fallback_route.id + assert runtime.steps[0].applied is False + assert runtime.steps[0].outcome == "rejected" + execution = OrchestrationExecution.get_by_id(runtime.execution_ids[0]) + assert execution.trace_json["applied"] is False + assert "disabled" in execution.trace_json["rejected_reason"] + + +def test_execution_trace_redacts_sensitive_payload_values(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, _always([]), mode="shadow") + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "dedup_key": "secret-1", + "title": "Secret", + "labels": {}, + "payload": { + "token": "must-not-be-stored", + "nested": {"client_secret": "also-secret"}, + }, + } + + runtime = run_event_orchestration(alert_data) + + execution = OrchestrationExecution.get_by_id(runtime.execution_ids[0]) + raw = execution.trace_json["initial_context"]["raw"] + assert raw["token"] == "***REDACTED***" + assert raw["nested"]["client_secret"] == "***REDACTED***" + + + +def test_runtime_state_requires_published_version(db): + group = create_group() + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="Unpublished runtime", + created_by_id=user.id, + ) + + with pytest.raises(OrchestrationConflict, match="Published version"): + set_runtime_state( + orchestration.id, + enabled=True, + mode="active", + compatibility_mode="hybrid", + ) + + +def test_runtime_state_rejects_incoherent_enabled_and_mode(db): + group = create_group() + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="Invalid runtime state", + created_by_id=user.id, + ) + + with pytest.raises(OrchestrationValidationError, match="enabled must be true"): + set_runtime_state( + orchestration.id, + enabled=False, + mode="active", + compatibility_mode="hybrid", + ) + + + +def test_lifecycle_applies_runtime_mutation_and_attaches_execution(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, _always([{"type": "set_title", "value": "Runtime title"}])) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": "runtime-lifecycle-1", + "dedup_key": "runtime-lifecycle-1", + "title": "Original title", + "message": "Original message", + "severity": "warning", + "status": "firing", + "labels": {"alertname": "RuntimeLifecycle"}, + "payload": {}, + } + + result = upsert_alert(alert_data) + + assert result.alert.title == "Runtime title" + execution = OrchestrationExecution.get( + OrchestrationExecution.event_fingerprint == "runtime-lifecycle-1" + ) + assert execution.alert_id == result.alert.id + assert execution.alert_group_id == result.group.id + + +def test_lifecycle_runs_service_orchestration_for_legacy_service_match(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + service = create_service(team, name="Lifecycle service", slug="lifecycle-service") + ServiceMatchRule.create( + team=team, + route=route, + service=service, + name="Lifecycle service matcher", + position=1, + enabled=True, + matchers={"labels": {"service": "lifecycle-service"}}, + ) + _publish( + group, + _always([{"type": "set_severity", "value": "critical"}]), + scope="service", + service=service, + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": "runtime-service-lifecycle-1", + "dedup_key": "runtime-service-lifecycle-1", + "title": "Service lifecycle", + "message": "Service lifecycle", + "severity": "warning", + "status": "firing", + "labels": { + "alertname": "RuntimeServiceLifecycle", + "service": "lifecycle-service", + }, + "payload": {}, + } + + result = upsert_alert(alert_data) + + assert result.alert.service_id == service.id + assert result.alert.severity == "critical" + execution = OrchestrationExecution.get( + OrchestrationExecution.event_fingerprint == "runtime-service-lifecycle-1" + ) + assert execution.alert_id == result.alert.id + + +def test_lifecycle_uses_orchestration_grouping_window(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish( + group, + _always([{"type": "set_grouping", "window_seconds": 17}]), + ) + captured = {} + + from app.modules.db import alerts_repo + + original_find_existing_alert = alerts_repo.find_existing_alert + + def capture_window(source, dedup_key, window_seconds): + captured["window_seconds"] = window_seconds + return original_find_existing_alert(source, dedup_key, window_seconds) + + monkeypatch.setattr(alerts_repo, "find_existing_alert", capture_window) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": "runtime-window-1", + "dedup_key": "runtime-window-1", + "title": "Runtime window", + "message": "Runtime window", + "severity": "warning", + "status": "firing", + "labels": {"alertname": "RuntimeWindow"}, + "payload": {}, + } + + upsert_alert(alert_data) + + assert captured["window_seconds"] == 17 + + +def test_lifecycle_persists_orchestration_notification_policy_override(db): + group = create_group() + team = create_team(group) + route = create_route( + team, + source="alertmanager", + notification_channel_mode="route_only", + ) + channel = create_channel(group, team) + policy = create_notification_policy(team) + rule = create_notification_policy_rule( + policy, + channels=[channel], + ) + _publish( + group, + _always([ + { + "type": "set_notification_policy", + "notification_policy_id": policy.id, + }, + ]), + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": "runtime-notification-policy-1", + "dedup_key": "runtime-notification-policy-1", + "title": "Notification policy override", + "message": "Notification policy override", + "severity": "warning", + "status": "firing", + "labels": {"alertname": "RuntimeNotificationPolicy"}, + "payload": {}, + } + + result = upsert_alert(alert_data) + resolution = resolve_notification_channels(result.group) + + assert result.alert.notification_policy_id == policy.id + assert result.group.notification_policy_id == policy.id + assert resolution.policy_id == policy.id + assert resolution.mode == "service_policy" + assert resolution.matched_rule_ids == [rule.id] + assert [resolved.id for resolved in resolution.channels] == [channel.id] + assert "orchestration_notification_policy_override" in resolution.notes + assert resolution.channel_sources[channel.id] == [ + { + "source": "orchestration_policy", + "rule_id": rule.id, + }, + ] + + +def test_lifecycle_applies_orchestration_escalation_and_priority_policies(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + assignee = create_user(group=group) + + escalation_policy = create_escalation_policy(team) + escalation_rule = create_escalation_policy_rule( + escalation_policy, + delay_seconds=0, + target_type="user", + user=assignee, + ) + + priority = incidents_repo.get_priority_by_slug("p1") + priority_policy = create_priority_policy(team) + priority_rule = create_priority_policy_rule( + priority_policy, + priority, + matchers={}, + ) + + _publish( + group, + _always([ + { + "type": "set_escalation_policy", + "escalation_policy_id": escalation_policy.id, + }, + { + "type": "set_priority_policy", + "priority_policy_id": priority_policy.id, + }, + ]), + ) + alert_data = { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": "runtime-policy-selection-1", + "dedup_key": "runtime-policy-selection-1", + "title": "Policy selection", + "message": "Policy selection", + "severity": "info", + "status": "firing", + "labels": {"alertname": "RuntimePolicySelection"}, + "payload": {}, + } + + result = upsert_alert(alert_data) + + assert result.alert.escalation_policy_id == escalation_policy.id + assert result.alert.escalation_rule_id == escalation_rule.id + assert result.alert.assignee_id == assignee.id + assert result.group.escalation_policy_id == escalation_policy.id + assert result.group.escalation_rule_id == escalation_rule.id + assert result.group.assignee_id == assignee.id + assert result.group.priority_slug == "p1" + + priority_step = next( + step + for step in alerts_repo.list_alert_explain_steps(result.trace.row) + if step.code == "priority_resolution" + ) + assert priority_step.data["policy_id"] == priority_policy.id + assert priority_step.data["policy_source"] == "orchestration" + assert priority_step.data["rule_id"] == priority_rule.id diff --git a/tests/orchestration/test_orchestration_versioning.py b/tests/orchestration/test_orchestration_versioning.py index 939b21e..840a36a 100644 --- a/tests/orchestration/test_orchestration_versioning.py +++ b/tests/orchestration/test_orchestration_versioning.py @@ -263,6 +263,7 @@ def test_new_orchestration_is_disabled_and_preserves_legacy_behavior(db): assert orchestration.enabled is False assert orchestration.mode == "disabled" + assert orchestration.compatibility_mode == "legacy" assert orchestration.active_version_id is None From 53556ef159d938808c2911259bd4d8d81f2c1c1c Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Wed, 22 Jul 2026 08:39:01 +0300 Subject: [PATCH 05/34] Implements suppress, drop and pause, secure webhook actions Part of #16 Closes #21, #22 --- ...60720090000_event_orchestration_runtime.py | 19 +- ...100000_event_orchestration_dispositions.py | 87 ++ ...0721110000_event_orchestration_webhooks.py | 19 + app/modules/crypto.py | 67 ++ app/modules/db/models.py | 185 ++++ app/modules/db/orchestrations_repo.py | 82 ++ app/modules/sso/crypto.py | 39 +- app/services/alerts/lifecycle.py | 171 +++- app/services/orchestration/__init__.py | 24 + app/services/orchestration/actions.py | 96 ++- app/services/orchestration/metrics.py | 96 +++ app/services/orchestration/pending.py | 551 ++++++++++++ app/services/orchestration/runtime.py | 114 ++- app/services/orchestration/webhooks.py | 798 ++++++++++++++++++ app/services/scheduler.py | 131 +++ app/services/serializers/alerts.py | 6 + app/services/serializers/incidents.py | 7 + app/settings.py | 60 +- requirements.txt | 1 + tests/conftest.py | 6 + .../test_orchestration_actions.py | 48 ++ .../test_orchestration_dispositions.py | 507 +++++++++++ .../test_orchestration_migration.py | 98 +++ .../test_orchestration_runtime.py | 3 + .../test_orchestration_webhooks.py | 491 +++++++++++ 25 files changed, 3645 insertions(+), 61 deletions(-) create mode 100644 app/migrations/20260721100000_event_orchestration_dispositions.py create mode 100644 app/migrations/20260721110000_event_orchestration_webhooks.py create mode 100644 app/modules/crypto.py create mode 100644 app/services/orchestration/metrics.py create mode 100644 app/services/orchestration/pending.py create mode 100644 app/services/orchestration/webhooks.py create mode 100644 tests/orchestration/test_orchestration_dispositions.py create mode 100644 tests/orchestration/test_orchestration_webhooks.py diff --git a/app/migrations/20260720090000_event_orchestration_runtime.py b/app/migrations/20260720090000_event_orchestration_runtime.py index 68bad1b..0a5ebb8 100644 --- a/app/migrations/20260720090000_event_orchestration_runtime.py +++ b/app/migrations/20260720090000_event_orchestration_runtime.py @@ -4,6 +4,7 @@ from playhouse.migrate import SchemaMigrator, migrate from app.db import init_database +from app.migrations.introspection import get_columns, get_indexes, table_exists db = init_database() @@ -28,20 +29,16 @@ ) -def _table_exists(table): - return table in db.get_tables() - - def _columns(table): - if not _table_exists(table): + if not table_exists(db, table): return set() - return {column.name for column in db.get_columns(table)} + return {column.name for column in get_columns(db, table)} def _indexes(table): - if not _table_exists(table): + if not table_exists(db, table): return [] - return list(db.get_indexes(table)) + return list(get_indexes(db, table)) def _has_index(table, columns): @@ -54,7 +51,7 @@ def upgrade(): operations = [] for table, columns in RUNTIME_COLUMNS.items(): - if not _table_exists(table): + if not table_exists(db, table): continue existing = _columns(table) for name, field_factory in columns.items(): @@ -67,7 +64,7 @@ def upgrade(): migrate(*operations) for table, columns in RUNTIME_INDEXES: - if _table_exists(table) and not _has_index(table, columns): + if table_exists(db, table) and not _has_index(table, columns): migrate(migrator.add_index(table, columns, unique=False)) @@ -75,7 +72,7 @@ def downgrade(): migrator = SchemaMigrator.from_database(db) for table, columns in reversed(tuple(RUNTIME_COLUMNS.items())): - if not _table_exists(table): + if not table_exists(db, table): continue removed_columns = set(columns) for index in _indexes(table): diff --git a/app/migrations/20260721100000_event_orchestration_dispositions.py b/app/migrations/20260721100000_event_orchestration_dispositions.py new file mode 100644 index 0000000..a064e2c --- /dev/null +++ b/app/migrations/20260721100000_event_orchestration_dispositions.py @@ -0,0 +1,87 @@ +"""Add suppress/drop/pause persistence for Event Orchestration.""" + +from peewee import BooleanField, TextField, SqliteDatabase +from playhouse.migrate import SchemaMigrator, migrate + +from app.db import init_database +from app.migrations.introspection import get_columns, get_indexes, table_exists +from app.modules.db.models import PendingOrchestratedEvent + + +db = init_database() +COLUMNS = { + "alert_group": { + "orchestration_suppressed": lambda: BooleanField(default=False), + "orchestration_suppress_reason": lambda: TextField(null=True), + }, + "alert": { + "orchestration_suppressed": lambda: BooleanField(default=False), + "orchestration_suppress_reason": lambda: TextField(null=True), + }, +} +INDEXES = ( + ("alert_group", ("orchestration_suppressed",)), + ("alert", ("orchestration_suppressed",)), +) + + +def _columns(table): + if not table_exists(db, table): + return set() + return {column.name for column in get_columns(db, table)} + + +def _indexes(table): + if not table_exists(db, table): + return [] + return list(get_indexes(db, table)) + + +def _has_index(table, columns): + expected = tuple(columns) + return any(tuple(index.columns) == expected for index in _indexes(table)) + + +def upgrade(): + migrator = SchemaMigrator.from_database(db) + operations = [] + for table, columns in COLUMNS.items(): + if not table_exists(db, table): + continue + existing = _columns(table) + for name, factory in columns.items(): + if name not in existing: + operations.append(migrator.add_column(table, name, factory())) + if operations: + migrate(*operations) + + for table, columns in INDEXES: + if table_exists(db, table) and not _has_index(table, columns): + migrate(migrator.add_index(table, columns, unique=False)) + + db.create_tables([PendingOrchestratedEvent], safe=True) + + +def downgrade(): + if table_exists(db, PendingOrchestratedEvent._meta.table_name): + db.drop_tables([PendingOrchestratedEvent], safe=True) + + migrator = SchemaMigrator.from_database(db) + for table, columns in reversed(tuple(COLUMNS.items())): + if not table_exists(db, table): + continue + removed = set(columns) + for index in _indexes(table): + if removed.intersection(index.columns): + migrate(migrator.drop_index(table, index.name)) + + for table, columns in reversed(tuple(COLUMNS.items())): + existing = _columns(table) + for name in reversed(tuple(columns)): + if name not in existing: + continue + if isinstance(db, SqliteDatabase): + operation = migrator.drop_column(table, name, legacy=True) + else: + operation = migrator.drop_column(table, name) + migrate(operation) diff --git a/app/migrations/20260721110000_event_orchestration_webhooks.py b/app/migrations/20260721110000_event_orchestration_webhooks.py new file mode 100644 index 0000000..868e89b --- /dev/null +++ b/app/migrations/20260721110000_event_orchestration_webhooks.py @@ -0,0 +1,19 @@ +"""Add asynchronous outbound webhook actions for Event Orchestration.""" + +from app.db import init_database +from app.migrations.introspection import table_exists +from app.modules.db.models import AutomationExecution, OrchestrationWebhookAction + + +db = init_database() +MODELS = (OrchestrationWebhookAction, AutomationExecution) + + +def upgrade(): + db.create_tables(list(MODELS), safe=True) + + +def downgrade(): + for model in reversed(MODELS): + if table_exists(db, model._meta.table_name): + db.drop_tables([model], safe=True) diff --git a/app/modules/crypto.py b/app/modules/crypto.py new file mode 100644 index 0000000..3dcc07f --- /dev/null +++ b/app/modules/crypto.py @@ -0,0 +1,67 @@ +"""Project-wide encryption helpers for secrets stored in the database.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from typing import Any + +from cryptography.fernet import Fernet + +from app.settings import Config + + +def _fernet(raw_key: str | None = None) -> Fernet: + """Build a stable Fernet key from an explicit or application secret.""" + raw_key = ( + raw_key + or getattr(Config, "SECRET_ENCRYPTION_KEY", None) + or Config.SECRET_KEY + ) + digest = hashlib.sha256(str(raw_key).encode("utf-8")).digest() + return Fernet(base64.urlsafe_b64encode(digest)) + + +def encrypt_secret(value: str | None, *, key: str | None = None) -> str | None: + """Encrypt a UTF-8 string for database storage.""" + if value in (None, ""): + return None + return _fernet(key).encrypt(str(value).encode("utf-8")).decode("utf-8") + + +def decrypt_secret(value: str | None, *, key: str | None = None) -> str | None: + """Decrypt a UTF-8 string previously produced by :func:`encrypt_secret`.""" + if not value: + return None + return _fernet(key).decrypt(value.encode("utf-8")).decode("utf-8") + + +def encrypt_json(value: Any) -> str | None: + """Serialize and encrypt a JSON-compatible value.""" + if value is None: + return None + encoded = json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + return encrypt_secret(encoded) + + +def decrypt_json(value: str | None, *, default: Any = None) -> Any: + """Decrypt and deserialize a JSON-compatible value.""" + plaintext = decrypt_secret(value) + if plaintext is None: + return default + return json.loads(plaintext) + + +__all__ = [ + "decrypt_json", + "decrypt_secret", + "encrypt_json", + "encrypt_secret", +] diff --git a/app/modules/db/models.py b/app/modules/db/models.py index 6d2ee0b..8e45bf1 100644 --- a/app/modules/db/models.py +++ b/app/modules/db/models.py @@ -1826,6 +1826,8 @@ class AlertGroup(BaseModel): maintenance_behavior = CharField(null=True) maintenance_suppressed = BooleanField(default=False, index=True) + orchestration_suppressed = BooleanField(default=False, index=True) + orchestration_suppress_reason = TextField(null=True) class Meta: table_name = "alert_group" @@ -1894,6 +1896,8 @@ class Alert(BaseModel): maintenance_behavior = CharField(null=True) maintenance_suppressed = BooleanField(default=False, index=True) + orchestration_suppressed = BooleanField(default=False, index=True) + orchestration_suppress_reason = TextField(null=True) labels = JSONTextField(null=True) payload = JSONTextField(null=True) status = CharField(default="firing") @@ -3045,4 +3049,185 @@ class Meta: ) +class PendingOrchestratedEvent(BaseModel): + """A paused normalized event waiting to enter the normal alert lifecycle.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="pending_orchestrated_events", + on_delete="CASCADE", + index=True, + ) + orchestration = ForeignKeyField( + EventOrchestration, + backref="pending_events", + on_delete="CASCADE", + index=True, + ) + version = ForeignKeyField( + EventOrchestrationVersion, + backref="pending_events", + on_delete="RESTRICT", + index=True, + ) + route = ForeignKeyField( + AlertRoute, + null=True, + backref="pending_orchestrated_events", + on_delete="SET NULL", + index=True, + ) + service = ForeignKeyField( + Service, + null=True, + backref="pending_orchestrated_events", + on_delete="SET NULL", + index=True, + ) + source = CharField(max_length=128, index=True) + integration_name = CharField(max_length=255, null=True) + dedup_key = CharField(max_length=255, index=True) + active_key = CharField(max_length=64, null=True, unique=True, index=True) + normalized_event_json = JSONTextField(default=dict) + context_json = JSONTextField(default=dict) + activation_at = DateTimeField(index=True) + status = CharField(max_length=32, default="pending", index=True) + attempts = IntegerField(default=0) + last_error = TextField(null=True) + claim_token = CharField(max_length=64, null=True, index=True) + claimed_at = DateTimeField(null=True, index=True) + next_attempt_at = DateTimeField(null=True, index=True) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + resolved_at = DateTimeField(null=True, index=True) + activated_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "pending_orchestrated_event" + indexes = ( + (("status", "activation_at"), False), + (("group", "source", "dedup_key"), False), + (("status", "next_attempt_at"), False), + ) + + + +ORCHESTRATION_WEBHOOK_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE") +ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_POLICIES = ("deny", "allowlist") +AUTOMATION_EXECUTION_STATUSES = ( + "pending", + "running", + "succeeded", + "failed", + "cancelled", +) + + +class OrchestrationWebhookAction(SoftDeleteModel): + """Reusable group-owned outbound webhook action configuration.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + group = ForeignKeyField( + Group, + backref="orchestration_webhook_actions", + on_delete="CASCADE", + index=True, + ) + name = CharField(max_length=255) + description = TextField(null=True) + url = TextField() + method = CharField(max_length=16, default="POST") + headers_encrypted = TextField(null=True) + body_template = TextField(null=True) + timeout_seconds = IntegerField(default=10) + retry_count = IntegerField(default=2) + private_network_policy = CharField(max_length=32, default="deny") + enabled = BooleanField(default=True, index=True) + created_by = ForeignKeyField( + User, + backref="created_orchestration_webhook_actions", + null=True, + on_delete="SET NULL", + ) + created_at = DateTimeField(default=utc_now, index=True) + updated_at = DateTimeField(default=utc_now) + + class Meta: + table_name = "orchestration_webhook_action" + indexes = ( + (("group", "name"), True), + (("group", "enabled"), False), + ) + + def save(self, *args, **kwargs): + self.method = str(self.method or "POST").upper() + if self.method not in ORCHESTRATION_WEBHOOK_METHODS: + raise ValueError("Invalid orchestration webhook method") + if self.private_network_policy not in ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_POLICIES: + raise ValueError("Invalid orchestration webhook private network policy") + if isinstance(self.timeout_seconds, bool) or not 1 <= int(self.timeout_seconds) <= 60: + raise ValueError("Webhook timeout must be between 1 and 60 seconds") + if isinstance(self.retry_count, bool) or not 0 <= int(self.retry_count) <= 10: + raise ValueError("Webhook retry count must be between 0 and 10") + self.updated_at = utc_now() + return super().save(*args, **kwargs) + + +class AutomationExecution(BaseModel): + """Queued and audited execution of one orchestration webhook action.""" + + id = AutoField() + uid = UUIDField(default=uuid.uuid4, unique=True, index=True) + action = ForeignKeyField( + OrchestrationWebhookAction, + backref="executions", + on_delete="RESTRICT", + index=True, + ) + orchestration_execution = ForeignKeyField( + OrchestrationExecution, + backref="automation_executions", + on_delete="CASCADE", + index=True, + ) + group = ForeignKeyField( + Group, + backref="automation_executions", + on_delete="CASCADE", + index=True, + ) + alert_group_id = IntegerField(null=True, index=True) + rule_path = CharField(max_length=512, null=True) + status = CharField(max_length=32, default="pending", index=True) + attempts = IntegerField(default=0) + idempotency_key = CharField(max_length=128, unique=True, index=True) + request_metadata_json = JSONTextField(default=dict) + request_headers_encrypted = TextField(null=True) + request_body_encrypted = TextField(null=True) + response_status = IntegerField(null=True) + response_excerpt_safe = TextField(null=True) + error_safe = TextField(null=True) + next_attempt_at = DateTimeField(null=True, index=True) + claim_token = CharField(max_length=64, null=True, index=True) + claimed_at = DateTimeField(null=True, index=True) + created_at = DateTimeField(default=utc_now, index=True) + started_at = DateTimeField(null=True, index=True) + finished_at = DateTimeField(null=True, index=True) + + class Meta: + table_name = "automation_execution" + indexes = ( + (("status", "next_attempt_at"), False), + (("group", "status", "created_at"), False), + (("orchestration_execution", "created_at"), False), + ) + + def save(self, *args, **kwargs): + if self.status not in AUTOMATION_EXECUTION_STATUSES: + raise ValueError("Invalid automation execution status") + return super().save(*args, **kwargs) + # END EVENT ORCHESTRATION V1 MODELS diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index 539c3af..71c0c9a 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -70,6 +70,10 @@ def __init__(self, errors: Sequence[str], warnings: Optional[Sequence[str]] = No "PriorityPolicy", ("priority_policy_id", "policy_id", "value"), ), + "enqueue_webhook": ( + "OrchestrationWebhookAction", + ("action_id", "webhook_action_id"), + ), } @@ -444,6 +448,13 @@ def _validate_action_references( if entity is None: errors.append(f"{rule_path}: referenced {model_name} does not exist") continue + if action_type == "enqueue_webhook" and ( + not bool(getattr(entity, "enabled", False)) + or bool(getattr(entity, "deleted", False)) + or getattr(entity, "deleted_at", None) is not None + ): + errors.append(f"{rule_path}: referenced webhook action is disabled") + continue entity_group_id = _entity_group_id(entity) if entity_group_id is None: @@ -543,12 +554,71 @@ def validate_version(version_id: int) -> Dict[str, Any]: } + +def _constant_condition_value(condition: Any) -> Optional[bool]: + """Return True/False for statically constant condition trees.""" + if not isinstance(condition, dict): + return None + if not condition: + return True + + logical = [key for key in ("all", "any", "none") if key in condition] + if len(logical) != 1 or len(condition) != 1: + return None + + key = logical[0] + children = condition.get(key) + if not isinstance(children, list): + return None + values = [_constant_condition_value(child) for child in children] + + if key == "all": + if any(value is False for value in values): + return False + if all(value is True for value in values): + return True + return None + + if key == "any": + if any(value is True for value in values): + return True + if all(value is False for value in values): + return False + return None + + # none is the negation of any(children). + if any(value is True for value in values): + return False + if all(value is False for value in values): + return True + return None + + +def _definition_has_catch_all_drop(definition: Dict[str, Any]) -> bool: + def walk(rules): + for rule in rules or []: + if not isinstance(rule, dict) or not bool(rule.get("enabled", True)): + continue + actions = rule.get("actions") or [] + if _constant_condition_value(rule.get("condition_tree") or {}) is True and any( + isinstance(action, dict) + and (action.get("type") or action.get("action")) == "drop" + for action in actions + ): + return True + if walk(rule.get("children") or []): + return True + return False + + return walk(definition.get("rules") or []) + def _publish_version_locked( orchestration: EventOrchestration, draft: EventOrchestrationVersion, *, actor_id: Optional[int], comment: Optional[str], + confirm_catch_all_drop: bool = False, ) -> EventOrchestrationVersion: if draft.orchestration_id != orchestration.id: raise OrchestrationConflict("Draft belongs to another orchestration") @@ -560,6 +630,14 @@ def _publish_version_locked( validation["errors"], validation["warnings"], ) + if ( + _definition_has_catch_all_drop(validation["definition"]) + and not confirm_catch_all_drop + ): + raise OrchestrationValidationError( + ["catch-all drop requires explicit publish confirmation"], + validation["warnings"], + ) now = _utcnow() EventOrchestrationVersion.update( @@ -606,6 +684,7 @@ def publish_draft( *, actor_id: Optional[int] = None, comment: Optional[str] = None, + confirm_catch_all_drop: bool = False, ) -> EventOrchestrationVersion: """Validate and atomically activate the current draft.""" @@ -623,6 +702,7 @@ def publish_draft( draft, actor_id=actor_id, comment=comment, + confirm_catch_all_drop=confirm_catch_all_drop, ) @@ -632,6 +712,7 @@ def rollback_to_version( *, actor_id: Optional[int] = None, comment: Optional[str] = None, + confirm_catch_all_drop: bool = False, ) -> EventOrchestrationVersion: """Publish a new immutable version copied from an earlier version.""" @@ -665,6 +746,7 @@ def rollback_to_version( draft, actor_id=actor_id, comment=draft.comment, + confirm_catch_all_drop=confirm_catch_all_drop, ) diff --git a/app/modules/sso/crypto.py b/app/modules/sso/crypto.py index 6ec0d1f..8f18794 100644 --- a/app/modules/sso/crypto.py +++ b/app/modules/sso/crypto.py @@ -1,33 +1,22 @@ -import base64 -import hashlib - -from cryptography.fernet import Fernet +"""SSO secret encryption using the historically configured SSO key.""" +from app.modules.crypto import decrypt_secret as _decrypt_secret +from app.modules.crypto import encrypt_secret as _encrypt_secret from app.settings import Config -def _fernet(): - """ - Build a stable Fernet key from configured SSO secret encryption key. - - For production, set [sso] secret_encryption_key in incidentrelay.conf. - If this value changes, existing encrypted provider secrets cannot be decrypted. - """ - raw_key = Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY - digest = hashlib.sha256(raw_key.encode("utf-8")).digest() - key = base64.urlsafe_b64encode(digest) - return Fernet(key) - - def encrypt_secret(value: str | None) -> str | None: - """Encrypt a secret string for database storage.""" - if value in (None, ""): - return None - return _fernet().encrypt(value.encode("utf-8")).decode("utf-8") + return _encrypt_secret( + value, + key=Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY, + ) def decrypt_secret(value: str | None) -> str | None: - """Decrypt a secret string from database storage.""" - if not value: - return None - return _fernet().decrypt(value.encode("utf-8")).decode("utf-8") + return _decrypt_secret( + value, + key=Config.SSO_SECRET_ENCRYPTION_KEY or Config.SECRET_KEY, + ) + + +__all__ = ["decrypt_secret", "encrypt_secret"] diff --git a/app/services/alerts/lifecycle.py b/app/services/alerts/lifecycle.py index 73690f7..ddfee62 100644 --- a/app/services/alerts/lifecycle.py +++ b/app/services/alerts/lifecycle.py @@ -1,7 +1,7 @@ import logging -from datetime import datetime from app import Config +from app.modules.common import utc_now from app.modules.db import alerts_repo, incidents_repo from app.services.alerts.escalation import apply_initial_escalation_policy_assignment from app.services.alerts.explain import AlertExplainTrace @@ -26,6 +26,10 @@ run_event_orchestration, run_service_orchestration, ) +from app.services.orchestration.pending import ( + resolve_pending_event, + store_paused_event, +) from app.services.incidents.stakeholders import notify_stakeholders from app.services.maintenance import get_maintenance_decision from app.services.notifications.delivery import notify_alert @@ -98,6 +102,25 @@ def upsert_alert(alert_data): reason=runtime.reason or "Event orchestration blocked processing.", result={"created": False, "group_id": None, "alert_id": None}, ) + if ( + runtime.disposition == "drop" + and (alert_data.get("status") or "firing") != "resolved" + ): + trace.step( + "orchestration", + "orchestration_event_dropped", + "success", + "Event dropped by orchestration", + runtime.disposition_reason, + ) + result = _stopped_result( + trace=trace, + outcome="dropped", + reason=runtime.disposition_reason or "Event dropped by orchestration.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + attach_runtime_executions(runtime, group=None, alert=None) + return result result = _upsert_alert(alert_data, trace, runtime=runtime) attach_runtime_executions(runtime, group=result.group, alert=result.alert) return result @@ -136,7 +159,14 @@ def _completed_result(*, trace, group, alert, created_group, outcome): def _resolve_policy_assignment( - route, service, rotation, maintenance_decision, trace, *, policy_override=None + route, + service, + rotation, + maintenance_decision, + trace, + *, + policy_override=None, + orchestration_suppressed=False, ): policy = policy_override or get_effective_escalation_policy(route, service) @@ -144,7 +174,7 @@ def _resolve_policy_assignment( apply_initial_escalation_policy_assignment(policy, rotation) ) - if maintenance_decision.pause_escalation_only: + if maintenance_decision.pause_escalation_only or orchestration_suppressed: next_escalation_at = None trace.policy_resolved(policy, policy_rule) @@ -176,6 +206,8 @@ def _create_group( silenced, priority_kwargs, maintenance_kwargs, + orchestration_suppressed=False, + orchestration_suppress_reason=None, ): return alerts_repo.create_alert_group( team=team.id if team else None, @@ -199,6 +231,8 @@ def _create_group( last_seen_at=last_seen_at, silenced=silenced, priority_set_manually=False, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, **priority_kwargs, **maintenance_kwargs, ) @@ -218,9 +252,8 @@ def _set_alert_routing_fields( alert.route = route.id if route else None alert.service = service.id if service else None alert.rotation = rotation.id if rotation else None - alert.notification_policy = ( - notification_policy.id if notification_policy else None - ) + if notification_policy is not None: + alert.notification_policy = notification_policy.id alert.group = group.id @@ -243,6 +276,8 @@ def _handle_existing_alert( now, policy_override=None, notification_policy_override=None, + orchestration_suppressed=False, + orchestration_suppress_reason=None, ): group = existing_alert.group or existing_group priority = priority_resolution.priority @@ -258,6 +293,7 @@ def _handle_existing_alert( maintenance_decision, trace, policy_override=policy_override, + orchestration_suppressed=orchestration_suppressed, ) ) @@ -279,6 +315,8 @@ def _handle_existing_alert( silenced=bool(existing_alert.silenced), priority_kwargs=priority_kwargs, maintenance_kwargs=maintenance_kwargs, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) created_group = True @@ -329,13 +367,19 @@ def _handle_existing_alert( group=group, ) - group.notification_policy = ( - notification_policy_override.id if notification_policy_override else None - ) - group.save(only=[group.__class__.notification_policy]) + if notification_policy_override is not None: + group.notification_policy = notification_policy_override.id + + existing_alert.orchestration_suppressed = orchestration_suppressed + existing_alert.orchestration_suppress_reason = orchestration_suppress_reason + group.orchestration_suppressed = orchestration_suppressed + group.orchestration_suppress_reason = orchestration_suppress_reason + group.save() - if maintenance_decision.pause_escalation_only: + if maintenance_decision.pause_escalation_only or orchestration_suppressed: existing_alert.next_escalation_at = None + group.next_escalation_at = None + group.save(only=[group.__class__.next_escalation_at]) apply_priority_to_existing_alert(existing_alert, priority) apply_maintenance_to_existing_alert(existing_alert, maintenance_decision) @@ -400,7 +444,16 @@ def _handle_existing_alert( refresh_business_impacts_safely_for_group(group, reason="existing_alert_update") - if maintenance_decision.suppress_notifications: + if orchestration_suppressed: + alerts_repo.clear_alert_group_notification(group) + trace.step( + "orchestration", + "orchestration_notifications_suppressed", + "success", + "Notifications suppressed by orchestration", + orchestration_suppress_reason, + ) + elif maintenance_decision.suppress_notifications: alerts_repo.clear_alert_group_notification(group) trace.notification_suppressed( behavior=maintenance_decision.behavior, @@ -541,6 +594,70 @@ def _upsert_alert(alert_data, trace, runtime=None): trace.rotation_resolved(rotation) status = alert_data.get("status") or "firing" + group_id = getattr(getattr(route, "team", None), "group_id", None) + + if status == "resolved" and group_id is not None: + pending = resolve_pending_event( + group_id=group_id, + source=alert_data.get("source"), + dedup_key=alert_data.get("dedup_key"), + trace=trace, + ) + if pending is not None: + return _stopped_result( + trace=trace, + outcome="resolved_before_activation", + reason="Paused event resolved before activation.", + result={ + "created": False, + "group_id": None, + "alert_id": None, + "pending_event_id": pending.id, + }, + ) + + if runtime is not None and runtime.disposition == "drop": + trace.step( + "orchestration", + "orchestration_event_dropped", + "success", + "Event dropped by orchestration", + runtime.disposition_reason, + ) + return _stopped_result( + trace=trace, + outcome="dropped", + reason=runtime.disposition_reason or "Event dropped by orchestration.", + result={"created": False, "group_id": None, "alert_id": None}, + ) + + if runtime is not None and runtime.disposition == "pause": + pending = store_paused_event( + alert_data, + runtime, + route=route, + service=service, + trace=trace, + ) + return _stopped_result( + trace=trace, + outcome="paused", + reason=runtime.disposition_reason or "Event activation paused by orchestration.", + result={ + "created": False, + "group_id": None, + "alert_id": None, + "pending_event_id": pending.id, + "activation_at": pending.activation_at.isoformat(), + }, + ) + + orchestration_suppressed = bool( + runtime is not None and runtime.disposition == "suppress" + ) + orchestration_suppress_reason = ( + runtime.disposition_reason if orchestration_suppressed else None + ) priority_resolution = resolve_incident_priority( alert_data, @@ -645,6 +762,8 @@ def _upsert_alert(alert_data, trace, runtime=None): notification_policy_override=( runtime.notification_policy if runtime else None ), + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) if status == "resolved": @@ -681,6 +800,7 @@ def _upsert_alert(alert_data, trace, runtime=None): maintenance_decision, trace, policy_override=(runtime.escalation_policy if runtime else None), + orchestration_suppressed=orchestration_suppressed, ) ) @@ -716,6 +836,8 @@ def _upsert_alert(alert_data, trace, runtime=None): silenced=bool(silence), priority_kwargs=priority_kwargs, maintenance_kwargs=maintenance_kwargs, + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, ) created_group = True @@ -751,9 +873,19 @@ def _upsert_alert(alert_data, trace, runtime=None): else: trace.group_reused(group) + group.orchestration_suppressed = orchestration_suppressed + group.orchestration_suppress_reason = orchestration_suppress_reason + group_fields = [ + group.__class__.orchestration_suppressed, + group.__class__.orchestration_suppress_reason, + ] + if orchestration_suppressed: + group.next_escalation_at = None + group_fields.append(group.__class__.next_escalation_at) if runtime is not None and runtime.notification_policy is not None: group.notification_policy = runtime.notification_policy.id - group.save(only=[group.__class__.notification_policy]) + group_fields.append(group.__class__.notification_policy) + group.save(only=group_fields) priority_state_before_recalculate = group_priority_state(group) @@ -798,6 +930,8 @@ def _upsert_alert(alert_data, trace, runtime=None): first_seen_at=now, last_seen_at=now, silenced=bool(silence), + orchestration_suppressed=orchestration_suppressed, + orchestration_suppress_reason=orchestration_suppress_reason, **priority_kwargs, **maintenance_kwargs, ) @@ -925,7 +1059,16 @@ def _upsert_alert(alert_data, trace, runtime=None): }, ) - if ( + if orchestration_suppressed: + alerts_repo.clear_alert_group_notification(group) + trace.step( + "orchestration", + "orchestration_notifications_suppressed", + "success", + "Notifications suppressed by orchestration", + orchestration_suppress_reason, + ) + elif ( status == "firing" and group.status == "firing" and not maintenance_decision.suppress_notifications diff --git a/app/services/orchestration/__init__.py b/app/services/orchestration/__init__.py index d35669d..903e901 100644 --- a/app/services/orchestration/__init__.py +++ b/app/services/orchestration/__init__.py @@ -72,3 +72,27 @@ "run_service_orchestration", ] # END EVENT ORCHESTRATION WS4 EXPORTS + +# BEGIN EVENT ORCHESTRATION WS6 EXPORTS +from .webhooks import ( + WebhookDeliveryError, + WebhookSecurityError, + WebhookValidationError, + create_webhook_action, + process_due_webhooks, + retry_failed_webhook, + serialize_webhook_action, + update_webhook_action, +) + +__all__ += [ + "WebhookDeliveryError", + "WebhookSecurityError", + "WebhookValidationError", + "create_webhook_action", + "process_due_webhooks", + "retry_failed_webhook", + "serialize_webhook_action", + "update_webhook_action", +] +# END EVENT ORCHESTRATION WS6 EXPORTS diff --git a/app/services/orchestration/actions.py b/app/services/orchestration/actions.py index b821046..822cf24 100644 --- a/app/services/orchestration/actions.py +++ b/app/services/orchestration/actions.py @@ -70,6 +70,7 @@ "suppress", "drop", "pause", + "enqueue_webhook", } ) @@ -107,10 +108,15 @@ def from_context(cls, context: Mapping[str, Any]) -> "EventActionState": result.setdefault("suppress_notifications", False) result.setdefault("dropped", False) result.setdefault("pause_seconds", None) + result.setdefault("pause_retrigger", "preserve") + result.setdefault("suppress_reason", None) + result.setdefault("pause_reason", None) + result.setdefault("drop_reason", None) result.setdefault("routing", {}) result.setdefault("policies", {}) result.setdefault("grouping", {}) result.setdefault("notes", []) + result.setdefault("webhooks", []) if result["disposition"] not in PROCESS_DISPOSITIONS: raise ActionValidationError("result.disposition is invalid", path="result.disposition") @@ -122,6 +128,8 @@ def from_context(cls, context: Mapping[str, Any]) -> "EventActionState": raise ActionValidationError("result.grouping must be an object", path="result.grouping") if not isinstance(result["notes"], list): raise ActionValidationError("result.notes must be a list", path="result.notes") + if not isinstance(result["webhooks"], list): + raise ActionValidationError("result.webhooks must be a list", path="result.webhooks") return cls( event=event, @@ -447,6 +455,25 @@ def validate_action(action: Any, *, path: str = "action") -> List[ValidationIssu seconds = action.get("seconds") if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: issues.append(ValidationIssue(f"{path}.seconds", "invalid_pause_duration", "pause duration must be between 1 and 604800 seconds")) + retrigger = action.get("retrigger", "preserve") + if retrigger not in {"preserve", "reset"}: + issues.append(ValidationIssue(f"{path}.retrigger", "invalid_pause_retrigger", "pause retrigger must be preserve or reset")) + + elif action_type == "enqueue_webhook": + action_id = action.get("action_id", action.get("webhook_action_id")) + if isinstance(action_id, bool) or not isinstance(action_id, int) or action_id <= 0: + issues.append(ValidationIssue( + f"{path}.action_id", + "invalid_webhook_action", + "enqueue_webhook requires a positive integer action_id", + )) + + if action_type in {"suppress", "pause", "drop"}: + reason = action.get("reason") + if reason is not None and not isinstance(reason, str): + issues.append(ValidationIssue(f"{path}.reason", "invalid_disposition_reason", "disposition reason must be a string")) + elif isinstance(reason, str) and ("{{" in reason or "}}" in reason): + issues.extend(validate_template(reason, path=f"{path}.reason")) return issues @@ -489,6 +516,29 @@ def _execute_grouping(action: Mapping[str, Any], state: EventActionState, *, pat return before, copy.deepcopy(grouping), tuple(references) +def _disposition_reason( + action: Mapping[str, Any], + state: EventActionState, + *, + path: str, +) -> Tuple[Optional[str], Tuple[str, ...]]: + reason = action.get("reason") + if reason in (None, ""): + return None, () + references: Tuple[str, ...] = () + if "{{" in reason or "}}" in reason: + rendered = render_template(reason, state.context()) + reason = rendered.value + references = rendered.references + reason = _scalar_text(reason, path=f"{path}.reason", allow_empty=True) + if len(reason) > MAX_NOTE_LENGTH: + raise ActionValidationError( + "disposition reason exceeds the size limit", + path=f"{path}.reason", + ) + return reason or None, references + + def _execute_action(action: Mapping[str, Any], state: EventActionState, *, path: str) -> Tuple[Any, Any, Tuple[str, ...], str]: action_type = action["type"] @@ -578,36 +628,76 @@ def _execute_action(action: Mapping[str, Any], state: EventActionState, *, path: state.result["notes"].append(note) return before, list(state.result["notes"]), references, "continue" + if action_type == "enqueue_webhook": + action_id = action.get("action_id", action.get("webhook_action_id")) + if isinstance(action_id, bool) or not isinstance(action_id, int) or action_id <= 0: + raise ActionValidationError( + "enqueue_webhook requires a positive integer action_id", + path=f"{path}.action_id", + ) + webhooks = state.result.setdefault("webhooks", []) + before = list(webhooks) + request = {"action_id": action_id} + webhooks.append(request) + return before, request, (), "continue" + if action_type == "suppress": + reason, references = _disposition_reason(action, state, path=path) before = { "disposition": state.result.get("disposition"), "suppress_notifications": state.result.get("suppress_notifications"), + "suppress_reason": state.result.get("suppress_reason"), } state.result["disposition"] = "suppress" state.result["suppress_notifications"] = True - return before, {"disposition": "suppress", "suppress_notifications": True}, (), "continue" + state.result["suppress_reason"] = reason + return before, { + "disposition": "suppress", + "suppress_notifications": True, + "reason": reason, + }, references, "continue" if action_type == "pause": seconds = action.get("seconds") if isinstance(seconds, bool) or not isinstance(seconds, int) or not 1 <= seconds <= MAX_PAUSE_SECONDS: raise ActionValidationError("pause duration must be between 1 and 604800 seconds", path=f"{path}.seconds") + retrigger = action.get("retrigger", "preserve") + if retrigger not in {"preserve", "reset"}: + raise ActionValidationError("pause retrigger must be preserve or reset", path=f"{path}.retrigger") + reason, references = _disposition_reason(action, state, path=path) before = { "disposition": state.result.get("disposition"), "pause_seconds": state.result.get("pause_seconds"), + "pause_retrigger": state.result.get("pause_retrigger"), + "pause_reason": state.result.get("pause_reason"), } state.result["disposition"] = "pause" state.result["pause_seconds"] = seconds - return before, {"disposition": "pause", "pause_seconds": seconds}, (), "continue" + state.result["pause_retrigger"] = retrigger + state.result["pause_reason"] = reason + return before, { + "disposition": "pause", + "pause_seconds": seconds, + "retrigger": retrigger, + "reason": reason, + }, references, "continue" if action_type == "drop": + reason, references = _disposition_reason(action, state, path=path) before = { "disposition": state.result.get("disposition"), "dropped": state.result.get("dropped"), + "drop_reason": state.result.get("drop_reason"), } state.result["disposition"] = "drop" state.result["dropped"] = True state.result["suppress_notifications"] = True - return before, {"disposition": "drop", "dropped": True}, (), "stop_orchestration" + state.result["drop_reason"] = reason + return before, { + "disposition": "drop", + "dropped": True, + "reason": reason, + }, references, "stop_orchestration" raise ActionValidationError(f"unsupported action {action_type!r}", path=f"{path}.type") diff --git a/app/services/orchestration/metrics.py b/app/services/orchestration/metrics.py new file mode 100644 index 0000000..85243ed --- /dev/null +++ b/app/services/orchestration/metrics.py @@ -0,0 +1,96 @@ +"""Operational disposition metrics for Event Orchestration.""" + +from __future__ import annotations + +from typing import Optional + +from peewee import fn + +from app.modules.db.models import ( + AutomationExecution, + OrchestrationExecution, + PendingOrchestratedEvent, +) +from app.settings import Config + + +DISPOSITIONS = ("process", "suppress", "pause", "drop") +WEBHOOK_STATUSES = ("pending", "running", "succeeded", "failed", "cancelled") +PENDING_STATUSES = ( + "pending", + "activating", + "failed", + "activated", + "resolved", + "cancelled", +) + + +def _count_by(query, model, field, known_values): + result = {value: 0 for value in known_values} + for row in query.select(field, fn.COUNT(model.id).alias("row_count")).group_by(field): + value = getattr(row, field.name, None) + if value is None: + value = "unknown" + result[value] = int(getattr(row, "row_count", 0) or 0) + return result + + +def get_orchestration_disposition_metrics( + *, + group_id: Optional[int] = None, + since=None, + until=None, +): + """Return portable DB-backed counts for disposition and pause states.""" + executions = OrchestrationExecution.select() + pending = PendingOrchestratedEvent.select() + webhooks = AutomationExecution.select() + + if group_id is not None: + executions = executions.where(OrchestrationExecution.group == group_id) + pending = pending.where(PendingOrchestratedEvent.group == group_id) + webhooks = webhooks.where(AutomationExecution.group == group_id) + if since is not None: + executions = executions.where(OrchestrationExecution.created_at >= since) + pending = pending.where(PendingOrchestratedEvent.created_at >= since) + webhooks = webhooks.where(AutomationExecution.created_at >= since) + if until is not None: + executions = executions.where(OrchestrationExecution.created_at < until) + pending = pending.where(PendingOrchestratedEvent.created_at < until) + webhooks = webhooks.where(AutomationExecution.created_at < until) + + dispositions = _count_by( + executions, + OrchestrationExecution, + OrchestrationExecution.disposition, + DISPOSITIONS, + ) + pending_statuses = _count_by( + pending, + PendingOrchestratedEvent, + PendingOrchestratedEvent.status, + PENDING_STATUSES, + ) + + webhook_statuses = _count_by( + webhooks, + AutomationExecution, + AutomationExecution.status, + WEBHOOK_STATUSES, + ) + + return { + "executions_total": sum(dispositions.values()), + "dispositions": dispositions, + "pending_events_total": sum(pending_statuses.values()), + "pending_statuses": pending_statuses, + "webhook_executions_total": sum(webhook_statuses.values()), + "webhook_statuses": webhook_statuses, + "dropped_trace_retention_days": int( + getattr(Config, "ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS", 7) + ), + } + + +__all__ = ["get_orchestration_disposition_metrics"] diff --git a/app/services/orchestration/pending.py b/app/services/orchestration/pending.py new file mode 100644 index 0000000..4c4eefd --- /dev/null +++ b/app/services/orchestration/pending.py @@ -0,0 +1,551 @@ +"""Persistence and activation workflow for paused orchestration events.""" + +from __future__ import annotations + +import copy +import hashlib +import logging +import uuid +from datetime import timedelta +from typing import Any, Dict, Mapping, Optional + +from peewee import IntegrityError + +from app.db import database_proxy as db +from app.modules.common import utc_now +from app.modules.db.models import ( + EventOrchestration, + EventOrchestrationVersion, + OrchestrationExecution, + PendingOrchestratedEvent, +) +from app.modules.redaction import redact_secrets +from app.services.alerts.explain import AlertExplainTrace +from app.services.alerts.result import AlertProcessingResult +from app.services.orchestration.runtime import ( + RuntimeResult, + attach_runtime_executions, + restore_runtime_result, +) +from app.services.validation import make_json_safe +from app.services.orchestration.webhooks import cleanup_webhook_executions +from app.settings import Config + +logger = logging.getLogger("oncall.orchestration.pending") + +TERMINAL_STATUSES = frozenset({"activated", "resolved", "cancelled"}) + + +def pending_active_key(group_id: int, source: str, dedup_key: str) -> str: + material = f"{int(group_id)}\x1f{source or ''}\x1f{dedup_key or ''}" + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +def _event_for_storage(alert_data: Mapping[str, Any]) -> Dict[str, Any]: + value = copy.deepcopy(dict(alert_data or {})) + value.pop("raw", None) + for key in tuple(value): + if str(key).startswith("_orchestration_"): + value.pop(key, None) + return make_json_safe(value) + + +def _terminal_event_snapshot(value: Mapping[str, Any]) -> Dict[str, Any]: + event = dict(value or {}) + return { + key: make_json_safe(event.get(key)) + for key in ( + "source", + "external_id", + "dedup_key", + "group_key", + "title", + "severity", + "status", + "labels", + ) + if key in event + } + + +def _runtime_context(runtime: RuntimeResult, previous=None) -> Dict[str, Any]: + previous = dict(previous or {}) + updates = list(previous.get("updates") or [])[-49:] + updates.append( + { + "at": utc_now().isoformat(), + "execution_ids": list(runtime.execution_ids), + "disposition": runtime.disposition, + } + ) + return redact_secrets( + make_json_safe( + { + "runtime": runtime.to_dict(), + "updates": updates, + } + ) + ) + + +def _source_models(runtime: RuntimeResult): + orchestration_id = runtime.disposition_orchestration_id + version_id = runtime.disposition_version_id + if not orchestration_id or not version_id: + raise ValueError("pause disposition is missing orchestration provenance") + orchestration = EventOrchestration.get_by_id(orchestration_id) + version = EventOrchestrationVersion.get_by_id(version_id) + return orchestration, version + + +def store_paused_event( + alert_data: Mapping[str, Any], + runtime: RuntimeResult, + *, + route=None, + service=None, + trace=None, + now=None, +) -> PendingOrchestratedEvent: + """Create or refresh the one active paused row for an event fingerprint.""" + now = now or utc_now() + group_id = runtime.group_id + if group_id is None and route is not None: + group_id = getattr(getattr(route, "team", None), "group_id", None) + if group_id is None: + raise ValueError("paused event requires a group") + + source = str(alert_data.get("source") or "") + dedup_key = str(alert_data.get("dedup_key") or "") + if not source or not dedup_key: + raise ValueError("paused event requires source and dedup_key") + + seconds = int(runtime.pause_seconds or 0) + if seconds <= 0: + raise ValueError("paused event requires a positive pause duration") + + orchestration, version = _source_models(runtime) + key = pending_active_key(group_id, source, dedup_key) + requested_activation_at = now + timedelta(seconds=seconds) + stored_event = _event_for_storage(alert_data) + route_id = getattr(route or runtime.route, "id", None) + service_id = getattr(service or runtime.service, "id", None) + integration_name = alert_data.get("integration_name") or source + + row = None + for _attempt in range(4): + with db.atomic(): + current = PendingOrchestratedEvent.get_or_none( + PendingOrchestratedEvent.active_key == key + ) + + if current is None: + try: + # The nested atomic block is a savepoint on PostgreSQL, so a + # concurrent unique-key insert does not poison the outer transaction. + with db.atomic(): + row = PendingOrchestratedEvent.create( + group=group_id, + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + source=source, + integration_name=integration_name, + dedup_key=dedup_key, + active_key=key, + normalized_event_json=stored_event, + context_json=_runtime_context(runtime), + activation_at=requested_activation_at, + status="pending", + created_at=now, + updated_at=now, + ) + except IntegrityError: + row = None + if row is not None: + break + continue + + context_json = _runtime_context(runtime, current.context_json) + + if current.status == "activating": + # Never clear or replace a live claim. Refresh the payload for + # observability, while the claimed worker completes atomically. + updated = ( + PendingOrchestratedEvent.update( + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + integration_name=integration_name, + normalized_event_json=stored_event, + context_json=context_json, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == current.id) + & (PendingOrchestratedEvent.active_key == key) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == current.claim_token) + ) + .execute() + ) + else: + activation_at = requested_activation_at + if runtime.pause_retrigger != "reset": + activation_at = current.activation_at + updated = ( + PendingOrchestratedEvent.update( + orchestration=orchestration.id, + version=version.id, + route=route_id, + service=service_id, + integration_name=integration_name, + normalized_event_json=stored_event, + context_json=context_json, + activation_at=activation_at, + status="pending", + attempts=0, + last_error=None, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + resolved_at=None, + activated_at=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == current.id) + & (PendingOrchestratedEvent.active_key == key) + & PendingOrchestratedEvent.status.in_(("pending", "failed")) + ) + .execute() + ) + + if updated == 1: + row = PendingOrchestratedEvent.get_by_id(current.id) + break + + if row is None: + raise RuntimeError("paused event changed concurrently; retry the request") + + if trace is not None: + trace.step( + "orchestration", + "orchestration_event_paused", + "success", + "Event activation paused", + runtime.disposition_reason, + pending_event_id=row.id, + activation_at=row.activation_at.isoformat(), + pause_seconds=seconds, + retrigger=runtime.pause_retrigger, + activation_in_progress=row.status == "activating", + ) + return row + +def resolve_pending_event( + *, + group_id: int, + source: str, + dedup_key: str, + trace=None, + now=None, +) -> Optional[PendingOrchestratedEvent]: + """Resolve a paused trigger before its activation transaction commits.""" + now = now or utc_now() + key = pending_active_key(group_id, source, dedup_key) + + with db.atomic(): + row = PendingOrchestratedEvent.get_or_none( + PendingOrchestratedEvent.active_key == key + ) + if row is None: + return None + + updated = ( + PendingOrchestratedEvent.update( + status="resolved", + active_key=None, + resolved_at=now, + updated_at=now, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + normalized_event_json=_terminal_event_snapshot( + row.normalized_event_json or {} + ), + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.active_key == key) + & PendingOrchestratedEvent.status.in_(("pending", "failed", "activating")) + ) + .execute() + ) + if updated != 1: + return None + row = PendingOrchestratedEvent.get_by_id(row.id) + + if trace is not None: + trace.step( + "orchestration", + "orchestration_pause_resolved_before_activation", + "success", + "Paused event resolved before activation", + "No alert or incident was created.", + pending_event_id=row.id, + ) + return row + +def _claim_one(row_id: int, *, now) -> Optional[PendingOrchestratedEvent]: + token = uuid.uuid4().hex + updated = ( + PendingOrchestratedEvent.update( + status="activating", + claim_token=token, + claimed_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row_id) + & (PendingOrchestratedEvent.status == "pending") + ) + .execute() + ) + if updated != 1: + return None + return PendingOrchestratedEvent.get( + (PendingOrchestratedEvent.id == row_id) + & (PendingOrchestratedEvent.claim_token == token) + ) + + +def _requeue_stale_claims(now) -> int: + cutoff = now - timedelta( + seconds=int(getattr(Config, "ORCHESTRATION_PENDING_CLAIM_TTL_SECONDS", 300)) + ) + return ( + PendingOrchestratedEvent.update( + status="pending", + claim_token=None, + claimed_at=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claimed_at <= cutoff) + ) + .execute() + ) + + +def _activation_runtime(row: PendingOrchestratedEvent) -> RuntimeResult: + context = row.context_json or {} + runtime = restore_runtime_result(context.get("runtime") or {}) + runtime.disposition = "process" + runtime.disposition_reason = None + runtime.pause_seconds = None + runtime.pause_retrigger = "preserve" + return runtime + + +def _mark_activation_failed(row, exc, *, now): + attempts = int(row.attempts or 0) + 1 + max_attempts = int(getattr(Config, "ORCHESTRATION_PENDING_MAX_ATTEMPTS", 5)) + error = str(redact_secrets(exc))[:2048] + if attempts >= max_attempts: + status = "failed" + next_attempt_at = None + else: + status = "pending" + base = int(getattr(Config, "ORCHESTRATION_PENDING_RETRY_BASE_SECONDS", 30)) + next_attempt_at = now + timedelta(seconds=min(base * (2 ** (attempts - 1)), 3600)) + + PendingOrchestratedEvent.update( + status=status, + attempts=attempts, + last_error=error, + claim_token=None, + claimed_at=None, + next_attempt_at=next_attempt_at, + updated_at=now, + ).where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ).execute() + + +def _activate_claimed( + row: PendingOrchestratedEvent, + *, + now, +) -> Optional[AlertProcessingResult]: + from app.services.alerts.lifecycle import _upsert_alert + + # This write acquires a row lock on PostgreSQL and the write lock on + # SQLite. Resolve requests either win before it or wait until the alert + # and pending-state transition commit together. + with db.atomic(): + locked = ( + PendingOrchestratedEvent.update( + claimed_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ) + .execute() + ) + if locked != 1: + return None + + row = PendingOrchestratedEvent.get_by_id(row.id) + alert_data = copy.deepcopy(row.normalized_event_json or {}) + runtime = _activation_runtime(row) + trace = AlertExplainTrace.start(alert_data) + trace.step( + "orchestration", + "orchestration_pause_activated", + "success", + "Paused event activation started", + pending_event_id=row.id, + scheduled_activation_at=row.activation_at.isoformat(), + ) + + result = _upsert_alert(alert_data, trace, runtime=runtime) + if result.group is None or result.alert is None: + raise RuntimeError( + result.reason or f"paused event activation ended with {result.outcome}" + ) + + attach_runtime_executions(runtime, group=result.group, alert=result.alert) + updated = ( + PendingOrchestratedEvent.update( + status="activated", + active_key=None, + activated_at=now, + normalized_event_json=_terminal_event_snapshot(alert_data), + claim_token=None, + claimed_at=None, + next_attempt_at=None, + last_error=None, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == row.id) + & (PendingOrchestratedEvent.status == "activating") + & (PendingOrchestratedEvent.claim_token == row.claim_token) + ) + .execute() + ) + if updated != 1: + raise RuntimeError("paused event activation claim was lost") + return result + +def process_due_pending_events(limit=100, *, now=None): + """Activate due paused events with atomic claims and bounded retries.""" + now = now or utc_now() + result = {"processed": 0, "activated": 0, "failed": 0, "requeued": 0} + result["requeued"] = _requeue_stale_claims(now) + + rows = list( + PendingOrchestratedEvent.select(PendingOrchestratedEvent.id) + .where( + (PendingOrchestratedEvent.status == "pending") + & (PendingOrchestratedEvent.activation_at <= now) + & ( + PendingOrchestratedEvent.next_attempt_at.is_null(True) + | (PendingOrchestratedEvent.next_attempt_at <= now) + ) + ) + .order_by( + PendingOrchestratedEvent.activation_at.asc(), + PendingOrchestratedEvent.id.asc(), + ) + .limit(int(limit)) + ) + + for candidate in rows: + claimed = _claim_one(candidate.id, now=now) + if claimed is None: + continue + result["processed"] += 1 + try: + activated = _activate_claimed(claimed, now=utc_now()) + if activated is not None: + result["activated"] += 1 + except Exception as exc: + logger.exception( + "paused orchestration event activation failed", + extra={"extra": {"pending_event_id": claimed.id}}, + ) + _mark_activation_failed(claimed, exc, now=utc_now()) + result["failed"] += 1 + return result + + +def retry_failed_pending_event(pending_event_id: int, *, now=None): + now = now or utc_now() + updated = ( + PendingOrchestratedEvent.update( + status="pending", + attempts=0, + last_error=None, + claim_token=None, + claimed_at=None, + next_attempt_at=now, + activation_at=now, + updated_at=now, + ) + .where( + (PendingOrchestratedEvent.id == pending_event_id) + & (PendingOrchestratedEvent.status == "failed") + ) + .execute() + ) + return updated == 1 + + +def cleanup_orchestration_retention(*, now=None): + now = now or utc_now() + executions_deleted = ( + OrchestrationExecution.delete() + .where( + OrchestrationExecution.expires_at.is_null(False) + & (OrchestrationExecution.expires_at <= now) + ) + .execute() + ) + retention_days = int( + getattr(Config, "ORCHESTRATION_PENDING_EVENT_RETENTION_DAYS", 30) + ) + cutoff = now - timedelta(days=retention_days) + pending_deleted = ( + PendingOrchestratedEvent.delete() + .where( + PendingOrchestratedEvent.status.in_(tuple(TERMINAL_STATUSES)) + & (PendingOrchestratedEvent.updated_at <= cutoff) + ) + .execute() + ) + webhook_executions_deleted = cleanup_webhook_executions(now=now) + return { + "executions_deleted": executions_deleted, + "pending_events_deleted": pending_deleted, + "webhook_executions_deleted": webhook_executions_deleted, + } + + +__all__ = [ + "cleanup_orchestration_retention", + "pending_active_key", + "process_due_pending_events", + "resolve_pending_event", + "retry_failed_pending_event", + "store_paused_event", +] diff --git a/app/services/orchestration/runtime.py b/app/services/orchestration/runtime.py index 1ab8e02..90417b2 100644 --- a/app/services/orchestration/runtime.py +++ b/app/services/orchestration/runtime.py @@ -5,10 +5,12 @@ import copy import logging import time +from datetime import timedelta from dataclasses import dataclass, field, replace from typing import Any, Dict, List, Mapping, Optional from app.modules.common import utc_now +from app.settings import Config from app.modules.db import orchestrations_repo from app.modules.db.models import ( AlertRoute, @@ -28,7 +30,6 @@ logger = logging.getLogger("oncall.orchestration.runtime") _COMPATIBILITY_ORDER = {"legacy": 0, "hybrid": 1, "orchestration": 2} -_UNSUPPORTED_RUNTIME_DISPOSITIONS = {"suppress", "pause", "drop"} class RuntimeOrchestrationError(RuntimeError): @@ -81,7 +82,12 @@ class RuntimeResult: steps: List[RuntimeStep] = field(default_factory=list) blocked: bool = False reason: Optional[str] = None - deferred_disposition: Optional[str] = None + disposition: str = "process" + disposition_reason: Optional[str] = None + pause_seconds: Optional[int] = None + pause_retrigger: str = "preserve" + disposition_orchestration_id: Optional[int] = None + disposition_version_id: Optional[int] = None evaluated_service_ids: List[int] = field(default_factory=list, repr=False) _context: Dict[str, Any] = field(default_factory=dict, repr=False) @@ -103,7 +109,13 @@ def to_dict(self): "grouping_window_seconds": self.grouping_window_seconds, "blocked": self.blocked, "reason": self.reason, - "deferred_disposition": self.deferred_disposition, + "disposition": self.disposition, + "disposition_reason": self.disposition_reason, + "pause_seconds": self.pause_seconds, + "pause_retrigger": self.pause_retrigger, + "disposition_orchestration_id": self.disposition_orchestration_id, + "disposition_version_id": self.disposition_version_id, + "evaluated_service_ids": list(self.evaluated_service_ids), "steps": [step.to_dict() for step in self.steps], } @@ -305,6 +317,12 @@ def _record_execution( if result is not None: disposition = result.context.get("result", {}).get("disposition") matched = result.matched_rule_count + expires_at = None + if disposition == "drop": + expires_at = utc_now() + timedelta( + days=int(getattr(Config, "ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS", 7)) + ) + row = OrchestrationExecution.create( group=orchestration.group_id, orchestration=orchestration.id, @@ -319,6 +337,7 @@ def _record_execution( matched_rule_count=matched, duration_ms=duration_ms, trace_json=trace, + expires_at=expires_at, ) return row.id @@ -617,9 +636,20 @@ def _evaluate_orchestrations( if "window_seconds" in grouping: runtime.grouping_window_seconds = grouping.get("window_seconds") - disposition = (context.get("result") or {}).get("disposition") - if disposition in _UNSUPPORTED_RUNTIME_DISPOSITIONS: - runtime.deferred_disposition = disposition + result_state = context.get("result") or {} + disposition = result_state.get("disposition") or "process" + if disposition in {"suppress", "pause", "drop"}: + runtime.disposition = disposition + runtime.disposition_orchestration_id = orchestration.id + runtime.disposition_version_id = step.version_id + if disposition == "suppress": + runtime.disposition_reason = result_state.get("suppress_reason") + elif disposition == "pause": + runtime.disposition_reason = result_state.get("pause_reason") + runtime.pause_seconds = result_state.get("pause_seconds") + runtime.pause_retrigger = result_state.get("pause_retrigger") or "preserve" + else: + runtime.disposition_reason = result_state.get("drop_reason") runtime._context = copy.deepcopy(context) return context, route, team, service @@ -879,12 +909,81 @@ def attach_runtime_executions( ): if runtime is None or not runtime.execution_ids: return + execution_ids = list(runtime.execution_ids) OrchestrationExecution.update( alert_group_id=getattr(group, "id", None), alert_id=getattr(alert, "id", None), - ).where(OrchestrationExecution.id.in_(runtime.execution_ids)).execute() + ).where(OrchestrationExecution.id.in_(execution_ids)).execute() + + # Queue outbound automation only after the orchestration decision and the + # lifecycle outcome have been persisted. The queue function is idempotent. + from app.services.orchestration.webhooks import enqueue_execution_webhooks + + for execution_id in execution_ids: + try: + enqueue_execution_webhooks( + execution_id, + alert_group_id=getattr(group, "id", None), + ) + except Exception: + logger.exception( + "failed to enqueue orchestration webhook actions", + extra={"extra": {"execution_id": execution_id}}, + ) + + +def restore_runtime_result(data: Mapping[str, Any]) -> RuntimeResult: + """Restore trusted runtime metadata stored with a paused event.""" + payload = dict(data or {}) + steps = [] + for item in payload.get("steps") or []: + try: + steps.append(RuntimeStep(**item)) + except (TypeError, ValueError): + continue + + runtime = RuntimeResult( + group_id=payload.get("group_id"), + compatibility_mode=payload.get("compatibility_mode") or "legacy", + route=AlertRoute.get_or_none(AlertRoute.id == payload.get("route_id")) + if payload.get("route_id") else None, + team=Team.get_or_none(Team.id == payload.get("team_id")) + if payload.get("team_id") else None, + service=Service.get_or_none(Service.id == payload.get("service_id")) + if payload.get("service_id") else None, + escalation_policy=EscalationPolicy.get_or_none( + EscalationPolicy.id == payload.get("escalation_policy_id") + ) if payload.get("escalation_policy_id") else None, + priority_policy=PriorityPolicy.get_or_none( + PriorityPolicy.id == payload.get("priority_policy_id") + ) if payload.get("priority_policy_id") else None, + notification_policy=NotificationPolicy.get_or_none( + NotificationPolicy.id == payload.get("notification_policy_id") + ) if payload.get("notification_policy_id") else None, + group_key=payload.get("group_key"), + grouping_window_seconds=payload.get("grouping_window_seconds"), + route_selected_by_orchestration=bool( + payload.get("route_selected_by_orchestration") + ), + steps=steps, + blocked=bool(payload.get("blocked")), + reason=payload.get("reason"), + disposition=payload.get("disposition") or "process", + disposition_reason=payload.get("disposition_reason"), + pause_seconds=payload.get("pause_seconds"), + pause_retrigger=payload.get("pause_retrigger") or "preserve", + disposition_orchestration_id=payload.get("disposition_orchestration_id"), + disposition_version_id=payload.get("disposition_version_id"), + evaluated_service_ids=[ + int(value) for value in payload.get("evaluated_service_ids") or [] + ], + ) + if runtime.team is None and runtime.route is not None: + runtime.team = runtime.route.team + return runtime + __all__ = [ "RuntimeOrchestrationError", "RuntimeResult", @@ -892,4 +991,5 @@ def attach_runtime_executions( "attach_runtime_executions", "run_event_orchestration", "run_service_orchestration", + "restore_runtime_result", ] diff --git a/app/services/orchestration/webhooks.py b/app/services/orchestration/webhooks.py new file mode 100644 index 0000000..b856b27 --- /dev/null +++ b/app/services/orchestration/webhooks.py @@ -0,0 +1,798 @@ +"""Asynchronous, audited and SSRF-resistant Event Orchestration webhooks.""" + +from __future__ import annotations + +import copy +import hashlib +import ipaddress +import json +import logging +import socket +import ssl +import uuid +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple +from urllib.parse import urljoin, urlsplit, urlunsplit + +import urllib3 +from peewee import fn + +from app.db import database_proxy as db +from app.modules.common import utc_now +from app.modules.crypto import decrypt_json, decrypt_secret, encrypt_json, encrypt_secret +from app.modules.db.models import ( + AutomationExecution, + OrchestrationExecution, + OrchestrationWebhookAction, +) +from app.modules.redaction import redact_secrets +from app.services.orchestration.templates import render_template, validate_template +from app.settings import Config + +logger = logging.getLogger("oncall.orchestration.webhooks") + +_ALLOWED_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308}) +_RETRYABLE_STATUSES = frozenset({408, 409, 425, 429}) +_HOP_BY_HOP_HEADERS = frozenset( + { + "connection", + "content-length", + "host", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + } +) +_TERMINAL_STATUSES = frozenset({"succeeded", "failed", "cancelled"}) + + +class WebhookValidationError(ValueError): + pass + + +class WebhookSecurityError(RuntimeError): + pass + + +class WebhookDeliveryError(RuntimeError): + def __init__(self, message: str, *, retryable: bool = True, status: int | None = None): + self.retryable = bool(retryable) + self.status = status + super().__init__(message) + + +@dataclass(frozen=True) +class WebhookResponse: + status: int + body: bytes + final_url: str + redirects: int + + +def _safe_error(value: Any, *, limit: int = 2048) -> str: + text = str(redact_secrets(value)) + return text if len(text) <= limit else text[:limit] + "…" + + +def _validate_url_syntax(url: str) -> str: + value = str(url or "").strip() + parsed = urlsplit(value) + if parsed.scheme.lower() not in {"http", "https"}: + raise WebhookValidationError("webhook URL must use http or https") + if parsed.username or parsed.password: + raise WebhookValidationError("webhook URL must not contain credentials") + if not parsed.hostname: + raise WebhookValidationError("webhook URL requires a hostname") + if parsed.fragment: + raise WebhookValidationError("webhook URL must not contain a fragment") + if redact_secrets(value) != value: + raise WebhookValidationError( + "webhook URL must not contain secret query parameters; use encrypted headers" + ) + if parsed.scheme.lower() == "http" and not bool( + getattr(Config, "ORCHESTRATION_WEBHOOK_ALLOW_HTTP", False) + ): + raise WebhookValidationError("webhook URL must use HTTPS") + return value + + +def _normalize_headers(headers: Optional[Mapping[str, Any]]) -> Dict[str, str]: + result: Dict[str, str] = {} + for raw_name, raw_value in dict(headers or {}).items(): + name = str(raw_name or "").strip() + if not name or any(ch in name for ch in "\r\n:"): + raise WebhookValidationError("invalid webhook header name") + if name.lower() in _HOP_BY_HOP_HEADERS: + raise WebhookValidationError(f"webhook header {name!r} is not allowed") + value = str(raw_value if raw_value is not None else "") + if "\r" in value or "\n" in value: + raise WebhookValidationError("webhook header values must not contain newlines") + if "{{" in value or "}}" in value: + issues = validate_template(value, path=f"headers.{name}") + if issues: + raise WebhookValidationError(issues[0].message) + result[name] = value + return result + + +def create_webhook_action( + *, + group_id: int, + name: str, + url: str, + method: str = "POST", + headers: Optional[Mapping[str, Any]] = None, + body_template: Optional[str] = None, + description: Optional[str] = None, + timeout_seconds: int = 10, + retry_count: int = 2, + private_network_policy: str = "deny", + enabled: bool = True, + actor_id: Optional[int] = None, +) -> OrchestrationWebhookAction: + """Create one encrypted group-owned webhook action.""" + name = str(name or "").strip() + if not name: + raise WebhookValidationError("webhook action name is required") + method = str(method or "POST").upper() + if method not in _ALLOWED_METHODS: + raise WebhookValidationError("unsupported webhook method") + url = _validate_url_syntax(url) + headers = _normalize_headers(headers) + if body_template is not None: + body_template = str(body_template) + issues = validate_template(body_template, path="body_template") + if issues: + raise WebhookValidationError(issues[0].message) + return OrchestrationWebhookAction.create( + group=group_id, + name=name, + description=description, + url=url, + method=method, + headers_encrypted=encrypt_json(headers) if headers else None, + body_template=body_template, + timeout_seconds=int(timeout_seconds), + retry_count=int(retry_count), + private_network_policy=private_network_policy, + enabled=bool(enabled), + created_by=actor_id, + ) + + +def update_webhook_action(action_id: int, **changes) -> OrchestrationWebhookAction: + """Update a webhook action while preserving encrypted headers when omitted.""" + action = OrchestrationWebhookAction.get_by_id(action_id) + if "name" in changes: + action.name = str(changes["name"] or "").strip() + if not action.name: + raise WebhookValidationError("webhook action name is required") + if "url" in changes: + action.url = _validate_url_syntax(changes["url"]) + if "method" in changes: + method = str(changes["method"] or "POST").upper() + if method not in _ALLOWED_METHODS: + raise WebhookValidationError("unsupported webhook method") + action.method = method + if "headers" in changes: + normalized_headers = _normalize_headers(changes["headers"]) + action.headers_encrypted = ( + encrypt_json(normalized_headers) if normalized_headers else None + ) + if "body_template" in changes: + template = changes["body_template"] + if template is not None: + template = str(template) + issues = validate_template(template, path="body_template") + if issues: + raise WebhookValidationError(issues[0].message) + action.body_template = template + for field_name in ( + "description", + "timeout_seconds", + "retry_count", + "private_network_policy", + "enabled", + ): + if field_name in changes: + setattr(action, field_name, changes[field_name]) + action.save() + return action + + +def serialize_webhook_action(action: OrchestrationWebhookAction) -> Dict[str, Any]: + """Return safe metadata without decrypting secret headers.""" + return { + "id": action.id, + "uid": str(action.uid), + "group_id": action.group_id, + "name": action.name, + "description": action.description, + "url": redact_secrets(action.url), + "method": action.method, + "has_headers": bool(action.headers_encrypted), + "body_template": action.body_template, + "timeout_seconds": action.timeout_seconds, + "retry_count": action.retry_count, + "private_network_policy": action.private_network_policy, + "enabled": bool(action.enabled), + "created_at": action.created_at, + "updated_at": action.updated_at, + } + + +def _iter_webhook_requests(rules: Iterable[Mapping[str, Any]]): + for rule in rules or (): + path = rule.get("path") + for step in rule.get("actions") or (): + if not step.get("success") or step.get("type") != "enqueue_webhook": + continue + after = step.get("after") or {} + action_id = after.get("action_id") if isinstance(after, Mapping) else None + if action_id: + yield int(action_id), str(path or "") + yield from _iter_webhook_requests(rule.get("children") or ()) + + +def _render_headers(headers: Mapping[str, str], context: Mapping[str, Any]) -> Dict[str, str]: + rendered: Dict[str, str] = {} + for name, value in headers.items(): + if "{{" in value or "}}" in value: + value = render_template(value, context).value + if "\r" in value or "\n" in value: + raise WebhookValidationError("rendered webhook header contains a newline") + rendered[name] = value + return rendered + + +def _render_body(action: OrchestrationWebhookAction, context: Mapping[str, Any]) -> str: + if action.body_template is not None: + return render_template(action.body_template, context).value + return json.dumps( + { + "event": copy.deepcopy(context.get("event") or {}), + "result": copy.deepcopy(context.get("result") or {}), + }, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + ) + + +def enqueue_execution_webhooks( + orchestration_execution_id: int, + *, + alert_group_id: Optional[int] = None, +) -> int: + """Materialize requested webhooks from an applied execution exactly once.""" + execution = OrchestrationExecution.get_by_id(orchestration_execution_id) + trace = execution.trace_json or {} + if not bool(trace.get("applied")): + return 0 + result = trace.get("result") or {} + context = result.get("context") or {} + queued = 0 + for ordinal, (action_id, rule_path) in enumerate( + _iter_webhook_requests(result.get("rules") or ()) + ): + action = OrchestrationWebhookAction.get_or_none( + (OrchestrationWebhookAction.id == action_id) + & (OrchestrationWebhookAction.group == execution.group_id) + & (OrchestrationWebhookAction.enabled == True) # noqa: E712 + & (OrchestrationWebhookAction.deleted == False) # noqa: E712 + & OrchestrationWebhookAction.deleted_at.is_null(True) + ) + if action is None: + logger.warning( + "orchestration webhook action skipped", + extra={"extra": { + "execution_id": execution.id, + "action_id": action_id, + "reason": "missing_or_disabled", + }}, + ) + continue + headers = _render_headers( + decrypt_json(action.headers_encrypted, default={}) or {}, + context, + ) + body = _render_body(action, context) + key_material = f"{execution.uid}:{action.uid}:{rule_path}:{ordinal}" + idempotency_key = hashlib.sha256(key_material.encode("utf-8")).hexdigest() + headers.setdefault("Idempotency-Key", idempotency_key) + headers.setdefault("User-Agent", "IncidentRelay-Orchestration/1") + if body and not any(name.lower() == "content-type" for name in headers): + headers["Content-Type"] = "application/json" + metadata = { + "url": action.url, + "method": action.method, + "timeout_seconds": int(action.timeout_seconds), + "retry_count": int(action.retry_count), + "private_network_policy": action.private_network_policy, + "header_names": sorted(headers), + "body_bytes": len(body.encode("utf-8")), + } + row, created = AutomationExecution.get_or_create( + idempotency_key=idempotency_key, + defaults={ + "action": action.id, + "orchestration_execution": execution.id, + "group": execution.group_id, + "alert_group_id": alert_group_id, + "rule_path": rule_path, + "status": "pending", + "request_metadata_json": metadata, + "request_headers_encrypted": encrypt_json(headers), + "request_body_encrypted": encrypt_secret(body), + "next_attempt_at": utc_now(), + }, + ) + if not created and alert_group_id and row.alert_group_id is None: + row.alert_group_id = alert_group_id + row.save(only=[AutomationExecution.alert_group_id]) + queued += int(created) + return queued + + +def _allowlist_networks() -> Sequence[Any]: + raw = str( + getattr(Config, "ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_ALLOWLIST", "") or "" + ) + result = [] + for item in raw.replace(";", ",").split(","): + item = item.strip() + if not item: + continue + try: + result.append(ipaddress.ip_network(item, strict=False)) + except ValueError as exc: + raise WebhookSecurityError("invalid private network allowlist") from exc + return tuple(result) + + +def _ip_allowed(address: ipaddress._BaseAddress, policy: str) -> bool: + unsafe = ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_multicast + or address.is_reserved + or address.is_unspecified + ) + if not unsafe: + return True + if policy != "allowlist": + return False + return any(address in network for network in _allowlist_networks()) + + +def resolve_webhook_target(url: str, *, private_network_policy: str = "deny"): + """Resolve and validate every address, then return one pinned target IP.""" + url = _validate_url_syntax(url) + parsed = urlsplit(url) + port = parsed.port or (443 if parsed.scheme.lower() == "https" else 80) + try: + literal = ipaddress.ip_address(parsed.hostname) + addresses = [literal] + except ValueError: + try: + infos = socket.getaddrinfo( + parsed.hostname, + port, + type=socket.SOCK_STREAM, + ) + except socket.gaierror as exc: + raise WebhookDeliveryError("webhook DNS resolution failed") from exc + addresses = [] + for info in infos: + try: + address = ipaddress.ip_address(info[4][0]) + except ValueError: + continue + if address not in addresses: + addresses.append(address) + if not addresses: + raise WebhookDeliveryError("webhook hostname resolved to no addresses") + if not all(_ip_allowed(address, private_network_policy) for address in addresses): + raise WebhookSecurityError("webhook target resolves to a blocked network") + return parsed, str(addresses[0]), port + + +def _host_header(parsed) -> str: + host = parsed.hostname or "" + try: + if ipaddress.ip_address(host).version == 6: + host = f"[{host}]" + except ValueError: + pass + default_port = 443 if parsed.scheme.lower() == "https" else 80 + return f"{host}:{parsed.port}" if parsed.port and parsed.port != default_port else host + + +def _request_once( + url: str, + *, + method: str, + headers: Mapping[str, str], + body: bytes, + timeout_seconds: int, + private_network_policy: str, +) -> Tuple[int, Mapping[str, str], bytes]: + parsed, target_ip, port = resolve_webhook_target( + url, + private_network_policy=private_network_policy, + ) + request_headers = dict(headers) + request_headers["Host"] = _host_header(parsed) + path = urlunsplit(("", "", parsed.path or "/", parsed.query, "")) + timeout = urllib3.Timeout(connect=timeout_seconds, read=timeout_seconds) + if parsed.scheme.lower() == "https": + pool = urllib3.HTTPSConnectionPool( + target_ip, + port=port, + timeout=timeout, + maxsize=1, + block=True, + retries=False, + cert_reqs=ssl.CERT_REQUIRED, + assert_hostname=parsed.hostname, + server_hostname=parsed.hostname, + ) + else: + pool = urllib3.HTTPConnectionPool( + target_ip, + port=port, + timeout=timeout, + maxsize=1, + block=True, + retries=False, + ) + response = None + try: + response = pool.urlopen( + method, + path, + body=body or None, + headers=request_headers, + redirect=False, + retries=False, + preload_content=False, + ) + limit = int(getattr(Config, "ORCHESTRATION_WEBHOOK_MAX_RESPONSE_BYTES", 65536)) + payload = response.read(amt=limit + 1, decode_content=True) + if len(payload) > limit: + raise WebhookDeliveryError( + "webhook response exceeded the configured size limit", + retryable=False, + status=int(response.status), + ) + return int(response.status), dict(response.headers), payload + except WebhookDeliveryError: + raise + except Exception as exc: + raise WebhookDeliveryError(f"webhook request failed: {exc.__class__.__name__}") from exc + finally: + if response is not None: + response.release_conn() + pool.close() + + +def deliver_webhook( + *, + url: str, + method: str, + headers: Mapping[str, str], + body: bytes, + timeout_seconds: int, + private_network_policy: str, +) -> WebhookResponse: + """Deliver one request with pinned DNS and bounded redirects.""" + current_url = url + current_method = method + current_body = body + max_redirects = int(getattr(Config, "ORCHESTRATION_WEBHOOK_MAX_REDIRECTS", 3)) + for redirects in range(max_redirects + 1): + status, response_headers, payload = _request_once( + current_url, + method=current_method, + headers=headers, + body=current_body, + timeout_seconds=timeout_seconds, + private_network_policy=private_network_policy, + ) + if status not in _REDIRECT_STATUSES: + return WebhookResponse(status, payload, current_url, redirects) + location = response_headers.get("Location") or response_headers.get("location") + if not location: + return WebhookResponse(status, payload, current_url, redirects) + if redirects >= max_redirects: + raise WebhookDeliveryError("webhook redirect limit exceeded", retryable=False) + current_url = urljoin(current_url, location) + _validate_url_syntax(current_url) + if status == 303: + current_method = "GET" + current_body = b"" + raise WebhookDeliveryError("webhook redirect limit exceeded", retryable=False) + + +def _requeue_stale_claims(now) -> int: + cutoff = now - timedelta( + seconds=int(getattr(Config, "ORCHESTRATION_WEBHOOK_CLAIM_TTL_SECONDS", 300)) + ) + return ( + AutomationExecution.update( + status="pending", + claim_token=None, + claimed_at=None, + next_attempt_at=now, + ) + .where( + (AutomationExecution.status == "running") + & (AutomationExecution.claimed_at <= cutoff) + ) + .execute() + ) + + +def _group_capacity(group_id: int, *, now) -> bool: + concurrency = int( + getattr(Config, "ORCHESTRATION_WEBHOOK_PER_GROUP_CONCURRENCY", 2) + ) + running = ( + AutomationExecution.select(fn.COUNT(AutomationExecution.id)) + .where( + (AutomationExecution.group == group_id) + & (AutomationExecution.status == "running") + ) + .scalar() + or 0 + ) + if concurrency > 0 and int(running) >= concurrency: + return False + rate = int( + getattr(Config, "ORCHESTRATION_WEBHOOK_PER_GROUP_RATE_PER_MINUTE", 60) + ) + if rate <= 0: + return True + since = now - timedelta(minutes=1) + recent = ( + AutomationExecution.select(fn.COUNT(AutomationExecution.id)) + .where( + (AutomationExecution.group == group_id) + & AutomationExecution.started_at.is_null(False) + & (AutomationExecution.started_at >= since) + ) + .scalar() + or 0 + ) + return int(recent) < rate + + +def _claim_one(execution_id: int, *, now) -> Optional[AutomationExecution]: + token = uuid.uuid4().hex + row = AutomationExecution.get_or_none(AutomationExecution.id == execution_id) + if row is None or not _group_capacity(row.group_id, now=now): + return None + updated = ( + AutomationExecution.update( + status="running", + claim_token=token, + claimed_at=now, + started_at=now, + attempts=AutomationExecution.attempts + 1, + error_safe=None, + ) + .where( + (AutomationExecution.id == execution_id) + & (AutomationExecution.status == "pending") + & ( + AutomationExecution.next_attempt_at.is_null(True) + | (AutomationExecution.next_attempt_at <= now) + ) + ) + .execute() + ) + if updated != 1: + return None + return AutomationExecution.get( + (AutomationExecution.id == execution_id) + & (AutomationExecution.claim_token == token) + ) + + +def _mark_succeeded(row: AutomationExecution, response: WebhookResponse, *, now): + excerpt = response.body.decode("utf-8", errors="replace") + excerpt = _safe_error(excerpt, limit=4096) + AutomationExecution.update( + status="succeeded", + response_status=response.status, + response_excerpt_safe=excerpt, + error_safe=None, + claim_token=None, + claimed_at=None, + next_attempt_at=None, + finished_at=now, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.status == "running") + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + + +def _mark_failed(row: AutomationExecution, exc: Exception, *, now): + metadata = row.request_metadata_json or {} + attempts = int(row.attempts or 0) + max_attempts = 1 + int(metadata.get("retry_count", row.action.retry_count) or 0) + retryable = bool(getattr(exc, "retryable", True)) + status_code = getattr(exc, "status", None) + if retryable and attempts < max_attempts: + status = "pending" + base = int(getattr(Config, "ORCHESTRATION_WEBHOOK_RETRY_BASE_SECONDS", 30)) + next_attempt_at = now + timedelta( + seconds=min(base * (2 ** max(attempts - 1, 0)), 3600) + ) + finished_at = None + else: + status = "failed" + next_attempt_at = None + finished_at = now + AutomationExecution.update( + status=status, + response_status=status_code, + error_safe=_safe_error(exc), + claim_token=None, + claimed_at=None, + next_attempt_at=next_attempt_at, + finished_at=finished_at, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.status == "running") + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + + +def _deliver_claimed(row: AutomationExecution, *, now): + action = row.action + if not action.enabled or action.deleted or action.deleted_at is not None: + AutomationExecution.update( + status="cancelled", + error_safe="webhook action is disabled", + claim_token=None, + claimed_at=None, + finished_at=now, + ).where( + (AutomationExecution.id == row.id) + & (AutomationExecution.claim_token == row.claim_token) + ).execute() + return "cancelled" + headers = decrypt_json(row.request_headers_encrypted, default={}) or {} + body = (decrypt_secret(row.request_body_encrypted) or "").encode("utf-8") + metadata = row.request_metadata_json or {} + response = deliver_webhook( + url=metadata.get("url") or action.url, + method=metadata.get("method") or action.method, + headers=headers, + body=body, + timeout_seconds=int( + metadata.get("timeout_seconds", action.timeout_seconds) + ), + private_network_policy=( + metadata.get("private_network_policy") + or action.private_network_policy + ), + ) + if 200 <= response.status < 300: + _mark_succeeded(row, response, now=now) + return "succeeded" + retryable = response.status in _RETRYABLE_STATUSES or response.status >= 500 + raise WebhookDeliveryError( + f"webhook returned HTTP {response.status}", + retryable=retryable, + status=response.status, + ) + + +def process_due_webhooks(limit=50, *, now=None) -> Dict[str, int]: + """Claim and deliver queued webhook actions with bounded retries.""" + now = now or utc_now() + result = { + "processed": 0, + "succeeded": 0, + "failed": 0, + "cancelled": 0, + "requeued": _requeue_stale_claims(now), + } + rows = list( + AutomationExecution.select(AutomationExecution.id) + .where( + (AutomationExecution.status == "pending") + & ( + AutomationExecution.next_attempt_at.is_null(True) + | (AutomationExecution.next_attempt_at <= now) + ) + ) + .order_by(AutomationExecution.created_at.asc(), AutomationExecution.id.asc()) + .limit(int(limit)) + ) + for candidate in rows: + claimed = _claim_one(candidate.id, now=now) + if claimed is None: + continue + result["processed"] += 1 + try: + outcome = _deliver_claimed(claimed, now=utc_now()) + result[outcome] += 1 + except Exception as exc: + logger.warning( + "orchestration webhook execution failed", + extra={"extra": { + "automation_execution_id": claimed.id, + "action_id": claimed.action_id, + "error": _safe_error(exc), + }}, + ) + _mark_failed(claimed, exc, now=utc_now()) + result["failed"] += 1 + return result + + +def retry_failed_webhook(execution_id: int, *, now=None) -> bool: + now = now or utc_now() + updated = ( + AutomationExecution.update( + status="pending", + attempts=0, + response_status=None, + response_excerpt_safe=None, + error_safe=None, + next_attempt_at=now, + claim_token=None, + claimed_at=None, + started_at=None, + finished_at=None, + ) + .where( + (AutomationExecution.id == execution_id) + & (AutomationExecution.status == "failed") + ) + .execute() + ) + return updated == 1 + + +def cleanup_webhook_executions(*, now=None) -> int: + now = now or utc_now() + cutoff = now - timedelta( + days=int(getattr(Config, "ORCHESTRATION_WEBHOOK_EXECUTION_RETENTION_DAYS", 30)) + ) + return ( + AutomationExecution.delete() + .where( + AutomationExecution.status.in_(tuple(_TERMINAL_STATUSES)) + & (AutomationExecution.finished_at <= cutoff) + ) + .execute() + ) + + +__all__ = [ + "WebhookDeliveryError", + "WebhookResponse", + "WebhookSecurityError", + "WebhookValidationError", + "cleanup_webhook_executions", + "create_webhook_action", + "deliver_webhook", + "enqueue_execution_webhooks", + "process_due_webhooks", + "resolve_webhook_target", + "retry_failed_webhook", + "serialize_webhook_action", + "update_webhook_action", +] diff --git a/app/services/scheduler.py b/app/services/scheduler.py index e5f76b7..af3539c 100644 --- a/app/services/scheduler.py +++ b/app/services/scheduler.py @@ -19,6 +19,11 @@ from app.services.service_catalog.impact_snapshots import capture_scheduled_service_impact_snapshot from app.services.heartbeats.service import process_overdue_heartbeats from app.modules.common import utc_now +from app.services.orchestration.pending import ( + cleanup_orchestration_retention, + process_due_pending_events, +) +from app.services.orchestration.webhooks import process_due_webhooks logger = logging.getLogger("oncall.scheduler") _scheduler = None @@ -474,6 +479,99 @@ def service_impact_snapshot_job(): db.close() + +def orchestration_pending_event_job(): + """Activate due paused orchestration events under a database lock.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_pending_event_job") + if not owner: + logger.debug("orchestration pending event job skipped because lock is busy") + return {"processed": 0, "activated": 0, "failed": 0, "requeued": 0} + result = process_due_pending_events( + limit=int(getattr(Config, "ORCHESTRATION_PENDING_BATCH_SIZE", 100)) + ) + logger.info( + "orchestration pending event job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration pending event job failed") + return {"processed": 0, "activated": 0, "failed": 1, "requeued": 0} + finally: + if owner: + release_db_lock("orchestration_pending_event_job", owner) + if not db.is_closed(): + db.close() + + +def orchestration_webhook_job(): + """Deliver queued orchestration webhook actions under a database lock.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_webhook_job") + if not owner: + logger.debug("orchestration webhook job skipped because lock is busy") + return { + "processed": 0, + "succeeded": 0, + "failed": 0, + "cancelled": 0, + "requeued": 0, + } + result = process_due_webhooks( + limit=int(getattr(Config, "ORCHESTRATION_WEBHOOK_BATCH_SIZE", 50)) + ) + logger.info( + "orchestration webhook job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration webhook job failed") + return { + "processed": 0, + "succeeded": 0, + "failed": 1, + "cancelled": 0, + "requeued": 0, + } + finally: + if owner: + release_db_lock("orchestration_webhook_job", owner) + if not db.is_closed(): + db.close() + + +def orchestration_retention_cleanup_job(): + """Prune expired dropped traces and terminal pending rows.""" + if db.is_closed(): + db.connect(reuse_if_open=True) + owner = None + try: + owner = acquire_db_lock("orchestration_retention_cleanup_job") + if not owner: + return {"executions_deleted": 0, "pending_events_deleted": 0} + result = cleanup_orchestration_retention() + logger.info( + "orchestration retention cleanup job finished", + extra={"extra": {"event_type": "scheduler", **result}}, + ) + return result + except Exception: + logger.exception("orchestration retention cleanup job failed") + return {"executions_deleted": 0, "pending_events_deleted": 0} + finally: + if owner: + release_db_lock("orchestration_retention_cleanup_job", owner) + if not db.is_closed(): + db.close() + def start_scheduler(): """ Start the background scheduler. @@ -620,6 +718,39 @@ def start_scheduler(): replace_existing=True, ) + _scheduler.add_job( + orchestration_pending_event_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_PENDING_CHECK_INTERVAL_SECONDS", 10)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_pending_event_job", + replace_existing=True, + ) + + _scheduler.add_job( + orchestration_webhook_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_WEBHOOK_CHECK_INTERVAL_SECONDS", 5)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_webhook_job", + replace_existing=True, + ) + + _scheduler.add_job( + orchestration_retention_cleanup_job, + "interval", + seconds=int(getattr(Config, "ORCHESTRATION_RETENTION_CLEANUP_INTERVAL_SECONDS", 86400)), + max_instances=1, + coalesce=True, + next_run_time=utc_now(), + id="orchestration_retention_cleanup_job", + replace_existing=True, + ) + try: _scheduler.start() except SchedulerAlreadyRunningError: diff --git a/app/services/serializers/alerts.py b/app/services/serializers/alerts.py index 81e6b17..b86718b 100644 --- a/app/services/serializers/alerts.py +++ b/app/services/serializers/alerts.py @@ -586,6 +586,12 @@ def serialize_alert_group( "team_escalation_enabled": group.team.escalation_enabled if group.team else None, "maintenance_window_id": group.maintenance_window_id, "maintenance_suppressed": group.maintenance_suppressed, + "orchestration_suppressed": bool( + getattr(group, "orchestration_suppressed", False) + ), + "orchestration_suppress_reason": getattr( + group, "orchestration_suppress_reason", None + ), "correlation_summary": serialize_alert_group_correlation_summary(group), "business_impact_summary": serialize_alert_group_business_impact_summary(group), } diff --git a/app/services/serializers/incidents.py b/app/services/serializers/incidents.py index ec679bd..5520675 100644 --- a/app/services/serializers/incidents.py +++ b/app/services/serializers/incidents.py @@ -114,6 +114,8 @@ def _add_optional_incident_alert_fields(data, alert): "maintenance_window_id", "maintenance_behavior", "maintenance_suppressed", + "orchestration_suppressed", + "orchestration_suppress_reason", ) for field_name in optional_fields: @@ -129,6 +131,11 @@ def _add_optional_incident_alert_fields(data, alert): if "maintenance_suppressed" in data: data["maintenance_suppressed"] = bool(data["maintenance_suppressed"]) + if "orchestration_suppressed" in data: + data["orchestration_suppressed"] = bool( + data["orchestration_suppressed"] + ) + def serialize_incident_alert(alert): payload = _as_dict(getattr(alert, "payload", None)) diff --git a/app/settings.py b/app/settings.py index ce335d7..7656bef 100644 --- a/app/settings.py +++ b/app/settings.py @@ -130,12 +130,70 @@ class Config: JWT_COOKIE_NAME = settings.get("auth", "jwt_cookie_name", "incidentrelay_jwt") JWT_COOKIE_SECURE = settings.get_bool("auth", "jwt_cookie_secure", False) - SSO_SECRET_ENCRYPTION_KEY = settings.get("sso", "secret_encryption_key", SECRET_KEY) + SECRET_ENCRYPTION_KEY = settings.get("main", "secret_encryption_key", SECRET_KEY) + SSO_SECRET_ENCRYPTION_KEY = settings.get("sso", "secret_encryption_key", SECRET_ENCRYPTION_KEY) REMINDER_INTERVAL_SECONDS = settings.get_int("alerts", "reminder_interval_seconds", 60) ALERT_GROUP_WINDOW_SECONDS = settings.get_int("alerts", "alert_group_window_seconds", 3600) SCHEDULER_LOCK_TTL_SECONDS = settings.get_int("scheduler", "lock_ttl_seconds", 120) + ORCHESTRATION_PENDING_CHECK_INTERVAL_SECONDS = settings.get_int( + "orchestration", "pending_check_interval_seconds", 10 + ) + ORCHESTRATION_PENDING_BATCH_SIZE = settings.get_int( + "orchestration", "pending_batch_size", 100 + ) + ORCHESTRATION_PENDING_CLAIM_TTL_SECONDS = settings.get_int( + "orchestration", "pending_claim_ttl_seconds", 300 + ) + ORCHESTRATION_PENDING_MAX_ATTEMPTS = settings.get_int( + "orchestration", "pending_max_attempts", 5 + ) + ORCHESTRATION_PENDING_RETRY_BASE_SECONDS = settings.get_int( + "orchestration", "pending_retry_base_seconds", 30 + ) + ORCHESTRATION_DROPPED_TRACE_RETENTION_DAYS = settings.get_int( + "orchestration", "dropped_trace_retention_days", 7 + ) + ORCHESTRATION_PENDING_EVENT_RETENTION_DAYS = settings.get_int( + "orchestration", "pending_event_retention_days", 30 + ) + ORCHESTRATION_RETENTION_CLEANUP_INTERVAL_SECONDS = settings.get_int( + "orchestration", "retention_cleanup_interval_seconds", 86400 + ) + ORCHESTRATION_WEBHOOK_CHECK_INTERVAL_SECONDS = settings.get_int( + "orchestration", "webhook_check_interval_seconds", 5 + ) + ORCHESTRATION_WEBHOOK_BATCH_SIZE = settings.get_int( + "orchestration", "webhook_batch_size", 50 + ) + ORCHESTRATION_WEBHOOK_CLAIM_TTL_SECONDS = settings.get_int( + "orchestration", "webhook_claim_ttl_seconds", 300 + ) + ORCHESTRATION_WEBHOOK_RETRY_BASE_SECONDS = settings.get_int( + "orchestration", "webhook_retry_base_seconds", 30 + ) + ORCHESTRATION_WEBHOOK_MAX_RESPONSE_BYTES = settings.get_int( + "orchestration", "webhook_max_response_bytes", 65536 + ) + ORCHESTRATION_WEBHOOK_MAX_REDIRECTS = settings.get_int( + "orchestration", "webhook_max_redirects", 3 + ) + ORCHESTRATION_WEBHOOK_ALLOW_HTTP = settings.get_bool( + "orchestration", "webhook_allow_http", False + ) + ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_ALLOWLIST = settings.get( + "orchestration", "webhook_private_network_allowlist", "" + ) + ORCHESTRATION_WEBHOOK_PER_GROUP_CONCURRENCY = settings.get_int( + "orchestration", "webhook_per_group_concurrency", 2 + ) + ORCHESTRATION_WEBHOOK_PER_GROUP_RATE_PER_MINUTE = settings.get_int( + "orchestration", "webhook_per_group_rate_per_minute", 60 + ) + ORCHESTRATION_WEBHOOK_EXECUTION_RETENTION_DAYS = settings.get_int( + "orchestration", "webhook_execution_retention_days", 30 + ) USER_NOTIFICATION_RULES_CHECK_INTERVAL_SECONDS = settings.get_int( "scheduler", "user_notification_rules_check_interval_seconds", diff --git a/requirements.txt b/requirements.txt index 63eddaf..7067c32 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ peewee==3.17.6 PyMySQL==1.1.1 psycopg2-binary==2.9.12 requests==2.32.3 +urllib3>=2.2,<3 APScheduler==3.10.4 python-dateutil==2.9.0.post0 pydantic==2.13.4 diff --git a/tests/conftest.py b/tests/conftest.py index 63fb94e..549b9ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,10 +91,16 @@ BusinessServiceStatusHistory, Heartbeat, HeartbeatPing, + PendingOrchestratedEvent, + OrchestrationWebhookAction, + AutomationExecution, ) CLEANUP_MODELS = [ + AutomationExecution, + OrchestrationWebhookAction, + PendingOrchestratedEvent, HeartbeatPing, AlertNotificationEvent, AlertNotification, diff --git a/tests/orchestration/test_orchestration_actions.py b/tests/orchestration/test_orchestration_actions.py index 4159b1a..36bd390 100644 --- a/tests/orchestration/test_orchestration_actions.py +++ b/tests/orchestration/test_orchestration_actions.py @@ -197,3 +197,51 @@ def test_invalid_static_reference_is_rejected_before_execution(): def test_unknown_action_is_rejected(): issues = validate_action_list([{"type": "run_python", "value": "print(1)"}]) assert issues[0].code == "unsupported_action" + + +def test_disposition_reasons_are_templated_and_pause_retrigger_is_explicit(): + context = build_context( + event={"title": "Database unavailable"}, + labels={"environment": "prod"}, + ) + + suppressed = execute_actions( + [ + { + "type": "suppress", + "reason": "{{ event.title }} in {{ labels.environment }}", + } + ], + context, + ) + assert suppressed.context["result"]["suppress_reason"] == ( + "Database unavailable in prod" + ) + + paused = execute_actions( + [ + { + "type": "pause", + "seconds": 90, + "retrigger": "reset", + "reason": "waiting for recovery", + } + ], + context, + ) + assert paused.context["result"]["pause_retrigger"] == "reset" + assert paused.context["result"]["pause_reason"] == "waiting for recovery" + + dropped = execute_actions( + [{"type": "drop", "reason": "irrelevant"}], + context, + ) + assert dropped.context["result"]["drop_reason"] == "irrelevant" + + +def test_pause_rejects_unknown_retrigger_mode(): + issues = validate_action_list( + [{"type": "pause", "seconds": 30, "retrigger": "extend"}] + ) + + assert any(issue.code == "invalid_pause_retrigger" for issue in issues) diff --git a/tests/orchestration/test_orchestration_dispositions.py b/tests/orchestration/test_orchestration_dispositions.py new file mode 100644 index 0000000..f28c388 --- /dev/null +++ b/tests/orchestration/test_orchestration_dispositions.py @@ -0,0 +1,507 @@ +from datetime import timedelta + +import pytest + +from app.modules.common import utc_now +from app.modules.db.models import ( + Alert, + AlertGroup, + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + PendingOrchestratedEvent, +) +from app.modules.db.orchestrations_repo import ( + OrchestrationValidationError, + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, + set_runtime_state, +) +from app.services.alerts.lifecycle import upsert_alert +from app.services.serializers.alerts import serialize_alert_group +from app.services.serializers.incidents import serialize_incident_alert +from app.services.orchestration.metrics import get_orchestration_disposition_metrics +from app.services.orchestration.pending import ( + cleanup_orchestration_retention, + process_due_pending_events, + resolve_pending_event, + retry_failed_pending_event, +) +from tests.factories import create_group, create_route, create_team, create_user + + +@pytest.fixture(autouse=True) +def orchestration_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + PendingOrchestratedEvent, + ], + safe=True, + ) + PendingOrchestratedEvent.delete().execute() + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _publish(group, actions): + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="disposition-test", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [ + { + "name": "always", + "condition_tree": {}, + "actions": actions, + "processing_mode": "continue", + } + ], + ) + publish_draft( + orchestration.id, + actor_id=user.id, + confirm_catch_all_drop=True, + ) + return set_runtime_state( + orchestration.id, + enabled=True, + mode="active", + compatibility_mode="hybrid", + ) + + +def _alert_data(route, dedup_key, *, status="firing"): + return { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": dedup_key, + "dedup_key": dedup_key, + "title": "Orchestration disposition", + "message": "test payload", + "severity": "warning", + "status": status, + "labels": {"alertname": "OrchestrationDisposition"}, + "payload": {"safe": True}, + "raw": {"authorization": "Bearer must-not-be-persisted"}, + } + + +def test_drop_keeps_short_lived_execution_without_creating_alert(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "drop", "reason": "ignored {{ event.title }}"}]) + + result = upsert_alert(_alert_data(route, "drop-1")) + + assert result.outcome == "dropped" + assert result.group is None + assert result.alert is None + assert Alert.select().count() == 0 + assert AlertGroup.select().count() == 0 + + execution = OrchestrationExecution.get( + OrchestrationExecution.event_fingerprint == "drop-1" + ) + assert execution.disposition == "drop" + assert execution.expires_at is not None + assert execution.alert_id is None + assert execution.alert_group_id is None + + +def test_suppress_creates_incident_without_scheduling_notifications(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "suppress", "reason": "maintenance-like"}]) + scheduled = [] + + monkeypatch.setattr( + "app.services.alerts.lifecycle.schedule_group_notification", + lambda *args, **kwargs: scheduled.append((args, kwargs)), + ) + + result = upsert_alert(_alert_data(route, "suppress-1")) + + assert result.group is not None + assert result.alert is not None + assert result.group.orchestration_suppressed is True + assert result.group.orchestration_suppress_reason == "maintenance-like" + assert result.alert.orchestration_suppressed is True + assert result.alert.orchestration_suppress_reason == "maintenance-like" + assert result.group.next_escalation_at is None + assert result.alert.next_escalation_at is None + assert result.group.notification_pending is False + assert scheduled == [] + + group_payload = serialize_alert_group(result.group) + alert_payload = serialize_incident_alert(result.alert) + assert group_payload["orchestration_suppressed"] is True + assert group_payload["orchestration_suppress_reason"] == "maintenance-like" + assert alert_payload["orchestration_suppressed"] is True + assert alert_payload["orchestration_suppress_reason"] == "maintenance-like" + + +def test_pause_creates_one_pending_row_and_preserves_first_activation(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish( + group, + [ + { + "type": "pause", + "seconds": 120, + "retrigger": "preserve", + "reason": "waiting for confirmation", + } + ], + ) + + first = upsert_alert(_alert_data(route, "pause-preserve-1")) + pending = PendingOrchestratedEvent.get() + fixed_activation = utc_now() + timedelta(minutes=10) + pending.activation_at = fixed_activation + pending.save(only=[PendingOrchestratedEvent.activation_at]) + + second = upsert_alert(_alert_data(route, "pause-preserve-1")) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert first.outcome == "paused" + assert second.outcome == "paused" + assert PendingOrchestratedEvent.select().count() == 1 + assert pending.status == "pending" + assert pending.activation_at == fixed_activation + assert pending.context_json["runtime"]["disposition_reason"] == "waiting for confirmation" + assert "raw" not in pending.normalized_event_json + assert Alert.select().count() == 0 + assert AlertGroup.select().count() == 0 + + +def test_pause_reset_restarts_activation_window(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish( + group, + [{"type": "pause", "seconds": 120, "retrigger": "reset"}], + ) + + upsert_alert(_alert_data(route, "pause-reset-1")) + pending = PendingOrchestratedEvent.get() + old_activation = utc_now() - timedelta(seconds=1) + pending.activation_at = old_activation + pending.save(only=[PendingOrchestratedEvent.activation_at]) + + upsert_alert(_alert_data(route, "pause-reset-1")) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert pending.activation_at > old_activation + assert pending.activation_at > utc_now() + timedelta(seconds=100) + + +def test_resolve_before_pause_activation_creates_no_incident(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-resolve-1")) + result = upsert_alert( + _alert_data(route, "pause-resolve-1", status="resolved") + ) + pending = PendingOrchestratedEvent.get() + + assert result.outcome == "resolved_before_activation" + assert pending.status == "resolved" + assert pending.active_key is None + assert pending.resolved_at is not None + assert Alert.select().count() == 0 + assert AlertGroup.select().count() == 0 + + +def test_due_pause_activation_enters_normal_lifecycle_once(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-activate-1")) + pending = PendingOrchestratedEvent.get() + pending.activation_at = utc_now() - timedelta(seconds=1) + pending.save(only=[PendingOrchestratedEvent.activation_at]) + + first = process_due_pending_events(now=utc_now()) + second = process_due_pending_events(now=utc_now()) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert first == {"processed": 1, "activated": 1, "failed": 0, "requeued": 0} + assert second == {"processed": 0, "activated": 0, "failed": 0, "requeued": 0} + assert pending.status == "activated" + assert pending.active_key is None + assert pending.activated_at is not None + assert Alert.select().where(Alert.dedup_key == "pause-activate-1").count() == 1 + assert AlertGroup.select().count() == 1 + + execution = OrchestrationExecution.get( + OrchestrationExecution.event_fingerprint == "pause-activate-1" + ) + assert execution.alert_id is not None + assert execution.alert_group_id is not None + + +def test_failed_activation_is_requeued_and_can_be_retried(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-failure-1")) + pending = PendingOrchestratedEvent.get() + pending.activation_at = utc_now() - timedelta(seconds=1) + pending.save(only=[PendingOrchestratedEvent.activation_at]) + + monkeypatch.setattr( + "app.services.alerts.lifecycle._upsert_alert", + lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("secret token=hidden")), + ) + + processed = process_due_pending_events(now=utc_now()) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert processed["failed"] == 1 + assert pending.status == "pending" + assert pending.attempts == 1 + assert pending.next_attempt_at is not None + assert "secret token=hidden" not in (pending.last_error or "") + + pending.status = "failed" + pending.next_attempt_at = None + pending.save() + assert retry_failed_pending_event(pending.id, now=utc_now()) is True + pending = PendingOrchestratedEvent.get_by_id(pending.id) + assert pending.status == "pending" + assert pending.attempts == 0 + + +def test_retrigger_during_activation_does_not_steal_worker_claim(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-claimed-retrigger-1")) + pending = PendingOrchestratedEvent.get() + pending.status = "activating" + pending.claim_token = "live-claim" + pending.claimed_at = utc_now() + pending.save() + + repeated = _alert_data(route, "pause-claimed-retrigger-1") + repeated["message"] = "newest payload" + result = upsert_alert(repeated) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert result.outcome == "paused" + assert pending.status == "activating" + assert pending.claim_token == "live-claim" + assert pending.claimed_at is not None + assert pending.normalized_event_json["message"] == "newest payload" + + +def test_resolve_winning_after_claim_cancels_activation(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-claim-resolve-1")) + pending = PendingOrchestratedEvent.get() + pending.activation_at = utc_now() - timedelta(seconds=1) + pending.save(only=[PendingOrchestratedEvent.activation_at]) + + from app.services.orchestration import pending as pending_service + + original_claim_one = pending_service._claim_one + + def claim_then_resolve(row_id, *, now): + claimed = original_claim_one(row_id, now=now) + assert claimed is not None + resolved = resolve_pending_event( + group_id=group.id, + source="alertmanager", + dedup_key="pause-claim-resolve-1", + now=now, + ) + assert resolved is not None + return claimed + + monkeypatch.setattr(pending_service, "_claim_one", claim_then_resolve) + + processed = process_due_pending_events(now=utc_now()) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert processed == {"processed": 1, "activated": 0, "failed": 0, "requeued": 0} + assert pending.status == "resolved" + assert pending.active_key is None + assert Alert.select().count() == 0 + assert AlertGroup.select().count() == 0 + + +def test_stale_activation_claim_is_requeued(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-stale-1")) + pending = PendingOrchestratedEvent.get() + pending.status = "activating" + pending.claim_token = "stale" + pending.claimed_at = utc_now() - timedelta(hours=1) + pending.activation_at = utc_now() + timedelta(hours=1) + pending.save() + + result = process_due_pending_events(now=utc_now()) + pending = PendingOrchestratedEvent.get_by_id(pending.id) + + assert result["requeued"] == 1 + assert pending.status == "pending" + assert pending.claim_token is None + assert pending.claimed_at is None + + +def test_retention_cleanup_removes_expired_drop_trace_and_terminal_pause(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "drop"}]) + + upsert_alert(_alert_data(route, "drop-expired-1")) + execution = OrchestrationExecution.get() + execution.expires_at = utc_now() - timedelta(seconds=1) + execution.save(only=[OrchestrationExecution.expires_at]) + + orchestration = EventOrchestration.get() + pending = PendingOrchestratedEvent.create( + group=group.id, + orchestration=orchestration.id, + version=orchestration.active_version_id, + route=route.id, + source="alertmanager", + dedup_key="terminal-old", + normalized_event_json={}, + context_json={}, + activation_at=utc_now() - timedelta(days=60), + status="resolved", + updated_at=utc_now() - timedelta(days=60), + ) + + result = cleanup_orchestration_retention(now=utc_now()) + + assert result == { + "executions_deleted": 1, + "pending_events_deleted": 1, + "webhook_executions_deleted": 0, + } + assert OrchestrationExecution.get_or_none(OrchestrationExecution.id == execution.id) is None + assert PendingOrchestratedEvent.get_or_none(PendingOrchestratedEvent.id == pending.id) is None + + +def test_disposition_metrics_count_executions_and_pending_states(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + _publish(group, [{"type": "pause", "seconds": 120}]) + + upsert_alert(_alert_data(route, "pause-metrics-1")) + metrics = get_orchestration_disposition_metrics(group_id=group.id) + + assert metrics["executions_total"] == 1 + assert metrics["dispositions"]["pause"] == 1 + assert metrics["pending_events_total"] == 1 + assert metrics["pending_statuses"]["pending"] == 1 + assert metrics["dropped_trace_retention_days"] > 0 + + +def test_catch_all_drop_requires_explicit_publish_confirmation(db): + group = create_group() + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="catch-all-drop-confirmation", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [{ + "name": "drop everything", + "condition_tree": {}, + "actions": [{"type": "drop"}], + "processing_mode": "continue", + }], + ) + + with pytest.raises(OrchestrationValidationError, match="explicit publish confirmation"): + publish_draft(orchestration.id, actor_id=user.id) + + published = publish_draft( + orchestration.id, + actor_id=user.id, + confirm_catch_all_drop=True, + ) + assert published.status == "published" + + +@pytest.mark.parametrize( + "condition_tree", + [ + {"all": []}, + {"none": []}, + {"all": [{}, {"all": []}]}, + {"any": [{"field": "event.severity", "operator": "equals", "value": "critical"}, {}]}, + ], +) +def test_logically_catch_all_drop_requires_confirmation(db, condition_tree): + group = create_group() + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="logical-catch-all-drop", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [{ + "name": "drop everything", + "condition_tree": condition_tree, + "actions": [{"type": "drop"}], + "processing_mode": "continue", + }], + ) + + with pytest.raises(OrchestrationValidationError, match="explicit publish confirmation"): + publish_draft(orchestration.id, actor_id=user.id) diff --git a/tests/orchestration/test_orchestration_migration.py b/tests/orchestration/test_orchestration_migration.py index 3da82b4..826a68b 100644 --- a/tests/orchestration/test_orchestration_migration.py +++ b/tests/orchestration/test_orchestration_migration.py @@ -13,19 +13,45 @@ def test_event_orchestration_migration_upgrade_and_downgrade(db): + webhooks_path = os.path.join( + get_migrations_dir(), + "20260721110000_event_orchestration_webhooks.py", + ) + webhooks_upgrade, webhooks_downgrade = load_migration_module(webhooks_path) + dispositions_path = os.path.join( + get_migrations_dir(), + "20260721100000_event_orchestration_dispositions.py", + ) + dispositions_upgrade, dispositions_downgrade = load_migration_module( + dispositions_path + ) + runtime_path = os.path.join( + get_migrations_dir(), + "20260720090000_event_orchestration_runtime.py", + ) + runtime_upgrade, runtime_downgrade = load_migration_module(runtime_path) path = os.path.join( get_migrations_dir(), "20260719090000_event_orchestration_models.py", ) upgrade, downgrade = load_migration_module(path) + # Dependent tables and columns must be removed before the base models. + webhooks_downgrade() + dispositions_downgrade() + runtime_downgrade() + upgrade() assert TABLES.issubset(set(db.get_tables())) downgrade() assert TABLES.isdisjoint(set(db.get_tables())) + # Restore the current schema for the shared migrated test database. upgrade() + runtime_upgrade() + dispositions_upgrade() + webhooks_upgrade() @@ -65,3 +91,75 @@ def test_event_orchestration_runtime_migration_upgrade_and_downgrade(db): assert column_name in { column.name for column in db.get_columns(table) } + + +def test_event_orchestration_dispositions_migration_upgrade_and_downgrade(db): + runtime_path = os.path.join( + get_migrations_dir(), + "20260720090000_event_orchestration_runtime.py", + ) + runtime_upgrade, _ = load_migration_module(runtime_path) + runtime_upgrade() + + path = os.path.join( + get_migrations_dir(), + "20260721100000_event_orchestration_dispositions.py", + ) + upgrade, downgrade = load_migration_module(path) + + upgrade() + assert "pending_orchestrated_event" in set(db.get_tables()) + expected_columns = { + "alert_group": { + "orchestration_suppressed", + "orchestration_suppress_reason", + }, + "alert": { + "orchestration_suppressed", + "orchestration_suppress_reason", + }, + } + for table, column_names in expected_columns.items(): + assert column_names.issubset( + {column.name for column in db.get_columns(table)} + ) + + downgrade() + assert "pending_orchestrated_event" not in set(db.get_tables()) + for table, column_names in expected_columns.items(): + assert column_names.isdisjoint( + {column.name for column in db.get_columns(table)} + ) + + upgrade() + assert "pending_orchestrated_event" in set(db.get_tables()) + for table, column_names in expected_columns.items(): + assert column_names.issubset( + {column.name for column in db.get_columns(table)} + ) + + +def test_event_orchestration_webhook_migration_upgrade_and_downgrade(db): + path = os.path.join( + get_migrations_dir(), + "20260721110000_event_orchestration_webhooks.py", + ) + upgrade, downgrade = load_migration_module(path) + + upgrade() + assert { + "orchestration_webhook_action", + "automation_execution", + }.issubset(set(db.get_tables())) + + downgrade() + assert { + "orchestration_webhook_action", + "automation_execution", + }.isdisjoint(set(db.get_tables())) + + upgrade() + assert { + "orchestration_webhook_action", + "automation_execution", + }.issubset(set(db.get_tables())) diff --git a/tests/orchestration/test_orchestration_runtime.py b/tests/orchestration/test_orchestration_runtime.py index 70fc101..e02e0b8 100644 --- a/tests/orchestration/test_orchestration_runtime.py +++ b/tests/orchestration/test_orchestration_runtime.py @@ -9,6 +9,7 @@ EventOrchestrationVersion, OrchestrationExecution, OrchestrationIntakeToken, + PendingOrchestratedEvent, ServiceMatchRule, ) from app.modules.db.orchestrations_repo import ( @@ -55,9 +56,11 @@ def orchestration_tables(db): EventOrchestrationRule, OrchestrationIntakeToken, OrchestrationExecution, + PendingOrchestratedEvent, ], safe=True, ) + PendingOrchestratedEvent.delete().execute() OrchestrationExecution.delete().execute() EventOrchestrationRule.delete().execute() EventOrchestrationVersion.delete().execute() diff --git a/tests/orchestration/test_orchestration_webhooks.py b/tests/orchestration/test_orchestration_webhooks.py new file mode 100644 index 0000000..c135952 --- /dev/null +++ b/tests/orchestration/test_orchestration_webhooks.py @@ -0,0 +1,491 @@ +from datetime import timedelta + +import pytest + +from app.modules.common import utc_now +from app.modules.crypto import decrypt_json, decrypt_secret +from app.modules.db.models import ( + AutomationExecution, + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + OrchestrationWebhookAction, +) +from app.modules.db.orchestrations_repo import ( + OrchestrationValidationError, + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, + set_runtime_state, +) +from app.services.alerts.lifecycle import upsert_alert +from app.services.orchestration.runtime import attach_runtime_executions, run_event_orchestration +from app.services.orchestration import webhooks as webhook_service +from app.services.orchestration.webhooks import ( + WebhookDeliveryError, + WebhookResponse, + WebhookSecurityError, + WebhookValidationError, + create_webhook_action, + deliver_webhook, + enqueue_execution_webhooks, + process_due_webhooks, + resolve_webhook_target, + serialize_webhook_action, +) +from tests.factories import create_group, create_route, create_team, create_user + + +@pytest.fixture(autouse=True) +def orchestration_webhook_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + OrchestrationWebhookAction, + AutomationExecution, + ], + safe=True, + ) + AutomationExecution.delete().execute() + OrchestrationWebhookAction.delete().execute() + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _publish(group, action_id, *, mode="active"): + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name=f"webhook-{mode}", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [ + { + "name": "enqueue diagnostics", + "condition_tree": {}, + "actions": [ + {"type": "enqueue_webhook", "action_id": action_id}, + ], + "processing_mode": "continue", + } + ], + ) + publish_draft(orchestration.id, actor_id=user.id) + return set_runtime_state( + orchestration.id, + enabled=True, + mode=mode, + compatibility_mode="hybrid", + ) + + +def _event(route, dedup="webhook-1"): + return { + "source": "alertmanager", + "forced_route_id": route.id, + "external_id": dedup, + "dedup_key": dedup, + "title": "Database unavailable", + "message": "connection failed", + "severity": "critical", + "status": "firing", + "labels": {"environment": "prod"}, + "payload": {}, + } + + +def test_webhook_action_encrypts_headers_and_serializes_safely(db): + group = create_group() + action = create_webhook_action( + group_id=group.id, + name="diagnostics", + url="https://hooks.example.test/diagnostics", + headers={"Authorization": "Bearer top-secret"}, + body_template='{"title":"{{ event.title }}"}', + ) + + assert "top-secret" not in action.headers_encrypted + assert decrypt_json(action.headers_encrypted)["Authorization"] == "Bearer top-secret" + payload = serialize_webhook_action(action) + assert payload["has_headers"] is True + assert "headers" not in payload + + +def test_webhook_action_rejects_secret_query_parameters(db): + group = create_group() + with pytest.raises(WebhookValidationError, match="encrypted headers"): + create_webhook_action( + group_id=group.id, + name="secret-in-url", + url="https://example.test/hook?token=must-not-be-stored", + ) + + +def test_webhook_action_rejects_http_by_default(db): + group = create_group() + with pytest.raises(WebhookValidationError, match="HTTPS"): + create_webhook_action( + group_id=group.id, + name="unsafe", + url="http://example.test/hook", + ) + + +def test_publication_rejects_webhook_action_from_another_group(db): + owner_group = create_group(slug="owner") + foreign_group = create_group(slug="foreign") + action = create_webhook_action( + group_id=foreign_group.id, + name="foreign", + url="https://example.test/hook", + ) + user = create_user(group=owner_group) + orchestration = create_orchestration( + group_id=owner_group.id, + name="invalid-reference", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [{ + "name": "bad", + "condition_tree": {}, + "actions": [{"type": "enqueue_webhook", "action_id": action.id}], + }], + ) + + with pytest.raises(OrchestrationValidationError, match="another group"): + publish_draft(orchestration.id, actor_id=user.id) + + +def test_publication_rejects_disabled_webhook_action(db): + group = create_group() + action = create_webhook_action( + group_id=group.id, + name="disabled", + url="https://example.test/hook", + enabled=False, + ) + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="disabled-reference", + scope="global", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [{ + "name": "disabled", + "condition_tree": {}, + "actions": [{"type": "enqueue_webhook", "action_id": action.id}], + }], + ) + + with pytest.raises(OrchestrationValidationError, match="disabled"): + publish_draft(orchestration.id, actor_id=user.id) + + +def test_lifecycle_queues_applied_webhook_once_with_encrypted_snapshot(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + action = create_webhook_action( + group_id=group.id, + name="diagnostics", + url="https://hooks.example.test/diagnostics", + headers={ + "Authorization": "Bearer top-secret", + "X-Alert-Title": "{{ event.title }}", + }, + body_template='{"title":"{{ event.title }}","severity":"{{ event.severity }}"}', + ) + _publish(group, action.id) + + result = upsert_alert(_event(route)) + + assert result.group is not None + queued = AutomationExecution.get() + assert queued.status == "pending" + assert queued.alert_group_id == result.group.id + assert queued.action_id == action.id + assert queued.orchestration_execution.alert_group_id == result.group.id + assert "top-secret" not in queued.request_headers_encrypted + headers = decrypt_json(queued.request_headers_encrypted) + assert headers["Authorization"] == "Bearer top-secret" + assert headers["X-Alert-Title"] == "Database unavailable" + assert headers["Idempotency-Key"] == queued.idempotency_key + assert decrypt_secret(queued.request_body_encrypted) == ( + '{"title":"Database unavailable","severity":"critical"}' + ) + + assert enqueue_execution_webhooks( + queued.orchestration_execution_id, + alert_group_id=result.group.id, + ) == 0 + assert AutomationExecution.select().count() == 1 + + +def test_shadow_execution_never_queues_webhook(db): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + action = create_webhook_action( + group_id=group.id, + name="shadow", + url="https://example.test/hook", + ) + _publish(group, action.id, mode="shadow") + event = _event(route, "shadow-webhook") + + runtime = run_event_orchestration(event) + attach_runtime_executions(runtime) + + assert runtime.steps[0].applied is False + assert AutomationExecution.select().count() == 0 + + +def test_resolver_blocks_loopback_and_mixed_dns_answers(monkeypatch): + monkeypatch.setattr( + "app.services.orchestration.webhooks.socket.getaddrinfo", + lambda *args, **kwargs: [ + (2, 1, 6, "", ("203.0.113.10", 443)), + (2, 1, 6, "", ("127.0.0.1", 443)), + ], + ) + + with pytest.raises(WebhookSecurityError, match="blocked network"): + resolve_webhook_target("https://example.test/hook") + + +def test_resolver_allows_explicit_private_network_allowlist(monkeypatch): + monkeypatch.setattr( + "app.services.orchestration.webhooks.socket.getaddrinfo", + lambda *args, **kwargs: [(2, 1, 6, "", ("10.20.30.40", 443))], + ) + monkeypatch.setattr( + "app.services.orchestration.webhooks.Config.ORCHESTRATION_WEBHOOK_PRIVATE_NETWORK_ALLOWLIST", + "10.20.0.0/16", + ) + + parsed, address, port = resolve_webhook_target( + "https://internal.example.test/hook", + private_network_policy="allowlist", + ) + + assert parsed.hostname == "internal.example.test" + assert address == "10.20.30.40" + assert port == 443 + + +def test_https_request_pins_resolved_ip_and_preserves_tls_hostname(monkeypatch): + captured = {} + + class FakeResponse: + status = 200 + headers = {} + + def read(self, **kwargs): + return b"ok" + + def release_conn(self): + captured["released"] = True + + class FakePool: + def __init__(self, host, **kwargs): + captured["host"] = host + captured["pool_kwargs"] = kwargs + + def urlopen(self, method, path, **kwargs): + captured["method"] = method + captured["path"] = path + captured["headers"] = kwargs["headers"] + return FakeResponse() + + def close(self): + captured["closed"] = True + + parsed = webhook_service.urlsplit("https://hooks.example.test:8443/run?q=1") + monkeypatch.setattr( + webhook_service, + "resolve_webhook_target", + lambda *args, **kwargs: (parsed, "198.51.100.25", 8443), + ) + monkeypatch.setattr(webhook_service.urllib3, "HTTPSConnectionPool", FakePool) + + status, headers, payload = webhook_service._request_once( + "https://hooks.example.test:8443/run?q=1", + method="POST", + headers={"X-Test": "yes"}, + body=b"{}", + timeout_seconds=5, + private_network_policy="deny", + ) + + assert status == 200 + assert payload == b"ok" + assert captured["host"] == "198.51.100.25" + assert captured["pool_kwargs"]["server_hostname"] == "hooks.example.test" + assert captured["pool_kwargs"]["assert_hostname"] == "hooks.example.test" + assert captured["headers"]["Host"] == "hooks.example.test:8443" + assert captured["path"] == "/run?q=1" + assert captured["released"] is True + assert captured["closed"] is True + + +def test_redirects_are_revalidated_for_every_target(monkeypatch): + calls = [] + responses = [ + (302, {"Location": "https://second.example.test/hook"}, b""), + (200, {}, b"ok"), + ] + + def fake_request_once(url, **kwargs): + calls.append(url) + return responses.pop(0) + + monkeypatch.setattr( + "app.services.orchestration.webhooks._request_once", + fake_request_once, + ) + + response = deliver_webhook( + url="https://first.example.test/hook", + method="POST", + headers={}, + body=b"{}", + timeout_seconds=5, + private_network_policy="deny", + ) + + assert calls == [ + "https://first.example.test/hook", + "https://second.example.test/hook", + ] + assert response.status == 200 + assert response.redirects == 1 + + +def test_worker_marks_success_and_never_logs_secret_response(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + action = create_webhook_action( + group_id=group.id, + name="success", + url="https://example.test/hook", + headers={"Authorization": "Bearer top-secret"}, + ) + _publish(group, action.id) + upsert_alert(_event(route, "worker-success")) + + monkeypatch.setattr( + "app.services.orchestration.webhooks.deliver_webhook", + lambda **kwargs: WebhookResponse( + status=204, + body=b'access_token=must-not-be-stored', + final_url=kwargs["url"], + redirects=0, + ), + ) + + result = process_due_webhooks(now=utc_now()) + + row = AutomationExecution.get() + assert result["succeeded"] == 1 + assert row.status == "succeeded" + assert row.response_status == 204 + assert "must-not-be-stored" not in (row.response_excerpt_safe or "") + assert "***REDACTED***" in row.response_excerpt_safe + + +def test_worker_uses_queued_request_snapshot_after_action_edit(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + action = create_webhook_action( + group_id=group.id, + name="snapshot", + url="https://original.example.test/hook", + retry_count=0, + ) + _publish(group, action.id) + upsert_alert(_event(route, "worker-snapshot")) + + action.url = "https://edited.example.test/hook" + action.method = "PUT" + action.timeout_seconds = 30 + action.save() + captured = {} + + def succeed(**kwargs): + captured.update(kwargs) + return WebhookResponse( + status=200, + body=b"ok", + final_url=kwargs["url"], + redirects=0, + ) + + monkeypatch.setattr( + "app.services.orchestration.webhooks.deliver_webhook", + succeed, + ) + + process_due_webhooks(now=utc_now()) + + assert captured["url"] == "https://original.example.test/hook" + assert captured["method"] == "POST" + assert captured["timeout_seconds"] == 10 + + +def test_worker_retries_then_marks_terminal_failure(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team, source="alertmanager") + action = create_webhook_action( + group_id=group.id, + name="retry", + url="https://example.test/hook", + retry_count=1, + ) + _publish(group, action.id) + upsert_alert(_event(route, "worker-retry")) + + def fail(**kwargs): + raise WebhookDeliveryError("temporary failure", status=503) + + monkeypatch.setattr("app.services.orchestration.webhooks.deliver_webhook", fail) + + first = process_due_webhooks(now=utc_now()) + row = AutomationExecution.get() + assert first["failed"] == 1 + assert row.status == "pending" + assert row.attempts == 1 + assert row.next_attempt_at is not None + + retry_at = row.next_attempt_at + timedelta(seconds=1) + second = process_due_webhooks(now=retry_at) + row = AutomationExecution.get_by_id(row.id) + assert second["failed"] == 1 + assert row.status == "failed" + assert row.attempts == 2 + assert row.finished_at is not None From b16a2af9325ec616b321da231e1898c8b1fe9c7c Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Wed, 22 Jul 2026 11:49:02 +0300 Subject: [PATCH 06/34] Fix test_event_orchestration_migration_upgrade_and_downgrade --- tests/orchestration/test_orchestration_migration.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/orchestration/test_orchestration_migration.py b/tests/orchestration/test_orchestration_migration.py index 826a68b..b1b6cb9 100644 --- a/tests/orchestration/test_orchestration_migration.py +++ b/tests/orchestration/test_orchestration_migration.py @@ -36,10 +36,13 @@ def test_event_orchestration_migration_upgrade_and_downgrade(db): ) upgrade, downgrade = load_migration_module(path) - # Dependent tables and columns must be removed before the base models. + # Roll back in reverse dependency order. webhooks_downgrade() dispositions_downgrade() runtime_downgrade() + downgrade() + + assert TABLES.isdisjoint(set(db.get_tables())) upgrade() assert TABLES.issubset(set(db.get_tables())) @@ -47,7 +50,7 @@ def test_event_orchestration_migration_upgrade_and_downgrade(db): downgrade() assert TABLES.isdisjoint(set(db.get_tables())) - # Restore the current schema for the shared migrated test database. + # Restore the current schema for the shared test database. upgrade() runtime_upgrade() dispositions_upgrade() From a9e3a4cf17a46f08d1ba8303518629066fee3725 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Thu, 23 Jul 2026 08:55:09 +0300 Subject: [PATCH 07/34] Implements simulator, replay, shadow mode and Explain Part of #16 Closes #23 --- app/__init__.py | 2 + app/api/schemas/orchestrations.py | 69 ++ .../integrations/normalizers/registry.py | 115 +++ app/services/orchestration/runtime.py | 116 ++- app/services/orchestration/safety.py | 176 ++++ app/services/orchestration/simulator.py | 969 ++++++++++++++++++ app/settings.py | 24 + app/views/integrations_view.py | 39 +- app/views/orchestrations_view.py | 168 +++ tests/conftest.py | 10 + .../integrations/test_normalizer_registry.py | 83 ++ .../test_orchestration_safety.py | 58 ++ .../test_orchestration_simulator.py | 402 ++++++++ .../test_orchestration_simulator_api.py | 239 +++++ 14 files changed, 2408 insertions(+), 62 deletions(-) create mode 100644 app/api/schemas/orchestrations.py create mode 100644 app/services/integrations/normalizers/registry.py create mode 100644 app/services/orchestration/safety.py create mode 100644 app/services/orchestration/simulator.py create mode 100644 app/views/orchestrations_view.py create mode 100644 tests/integrations/test_normalizer_registry.py create mode 100644 tests/orchestration/test_orchestration_safety.py create mode 100644 tests/orchestration/test_orchestration_simulator.py create mode 100644 tests/orchestration/test_orchestration_simulator_api.py diff --git a/app/__init__.py b/app/__init__.py index 69c4af0..8465490 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -41,6 +41,7 @@ from app.views.matchers_view import matchers_bp from app.views.business_services.routes import business_services_bp from app.views.heartbeats_view import heartbeats_bp +from app.views.orchestrations_view import orchestrations_bp def create_app(log_role=None): @@ -130,3 +131,4 @@ def register_blueprints(flask_app): flask_app.register_blueprint(priority_policies_bp, url_prefix="/api/priority-policies") flask_app.register_blueprint(business_services_bp) flask_app.register_blueprint(heartbeats_bp, url_prefix="/api/heartbeats") + flask_app.register_blueprint(orchestrations_bp, url_prefix="/api/event-orchestrations") diff --git a/app/api/schemas/orchestrations.py b/app/api/schemas/orchestrations.py new file mode 100644 index 0000000..6bbda7f --- /dev/null +++ b/app/api/schemas/orchestrations.py @@ -0,0 +1,69 @@ +"""Request schemas for Event Orchestration simulation and replay.""" + +from typing import Any, Dict + +from pydantic import Field, field_validator, model_validator + +from app.api.schemas.base import ApiModel +from app.services.integrations.normalizers.registry import ( + SUPPORTED_NORMALIZER_SOURCES, +) + + +class OrchestrationSimulationSchema(ApiModel): + source: str | None = Field(default=None, max_length=128) + payload: Any | None = None + headers: Dict[str, Any] = Field(default_factory=dict) + normalized_event: Dict[str, Any] | None = None + event_index: int = Field(default=0, ge=0, le=1000) + version_id: int | None = Field(default=None, ge=1) + compare_with_active: bool = False + + @field_validator("source") + @classmethod + def validate_source(cls, value): + if value is None: + return None + source = value.strip().lower() + if source not in SUPPORTED_NORMALIZER_SOURCES: + raise ValueError("unsupported simulation source") + return source + + @model_validator(mode="after") + def validate_input_mode(self): + has_normalized = self.normalized_event is not None + has_payload = self.payload is not None + if has_normalized == has_payload: + raise ValueError( + "provide exactly one of normalized_event or payload" + ) + if has_payload and not self.source: + raise ValueError("source is required when payload is provided") + return self + + +class OrchestrationReplaySchema(ApiModel): + alert_ids: list[int] = Field(default_factory=list) + execution_ids: list[int] = Field(default_factory=list) + version_id: int | None = Field(default=None, ge=1) + compare_with_active: bool = False + + @field_validator("alert_ids", "execution_ids") + @classmethod + def validate_ids(cls, value): + result = [] + for raw in value: + item = int(raw) + if item < 1: + raise ValueError("ids must be greater than 0") + if item not in result: + result.append(item) + return result + + @model_validator(mode="after") + def require_inputs(self): + if not self.alert_ids and not self.execution_ids: + raise ValueError( + "at least one alert_id or execution_id is required" + ) + return self diff --git a/app/services/integrations/normalizers/registry.py b/app/services/integrations/normalizers/registry.py new file mode 100644 index 0000000..4c63874 --- /dev/null +++ b/app/services/integrations/normalizers/registry.py @@ -0,0 +1,115 @@ +"""Shared registry for integration payload normalizers. + +Both production ingestion and Event Orchestration simulation use this module, +so adding a normalizer requires updating one registry only. +""" + +from __future__ import annotations + +import copy +from typing import Any, Callable, Dict, Mapping, Optional + +from app.services.integrations.normalizers.alertmanager import normalize_alertmanager +from app.services.integrations.normalizers.aws_sns import normalize_aws_sns +from app.services.integrations.normalizers.datadog import normalize_datadog +from app.services.integrations.normalizers.grafana import normalize_grafana +from app.services.integrations.normalizers.librenms import normalize_librenms +from app.services.integrations.normalizers.rmon import normalize_rmon +from app.services.integrations.normalizers.sentry import normalize_sentry +from app.services.integrations.normalizers.webhook import normalize_webhook +from app.services.integrations.normalizers.zabbix import normalize_zabbix + + +class UnknownNormalizerSource(ValueError): + """Raised when no payload normalizer is registered for a source.""" + + +Normalizer = Callable[ + [Mapping[str, Any], Mapping[str, Any], Mapping[str, Any]], + Any, +] + + +def _payload_only(normalizer: Callable[[Mapping[str, Any]], Any]) -> Normalizer: + def wrapped( + payload: Mapping[str, Any], + headers: Mapping[str, Any], + route_config: Mapping[str, Any], + ) -> Any: + del headers, route_config + return normalizer(payload) + + return wrapped + + +def _normalize_sentry( + payload: Mapping[str, Any], + headers: Mapping[str, Any], + route_config: Mapping[str, Any], +) -> Any: + return normalize_sentry( + payload, + headers=dict(headers), + route_config=dict(route_config), + ) + + +_NORMALIZERS: Dict[str, Normalizer] = { + "alertmanager": _payload_only(normalize_alertmanager), + "aws_sns": _payload_only(normalize_aws_sns), + "datadog": _payload_only(normalize_datadog), + "grafana": _payload_only(normalize_grafana), + "librenms": _payload_only(normalize_librenms), + "rmon": _payload_only(normalize_rmon), + "sentry": _normalize_sentry, + "webhook": _payload_only(normalize_webhook), + "zabbix": _payload_only(normalize_zabbix), +} + +SUPPORTED_NORMALIZER_SOURCES = frozenset(_NORMALIZERS) + + +def get_normalizer(source: str) -> Normalizer: + normalized_source = str(source or "").strip().lower() + normalizer = _NORMALIZERS.get(normalized_source) + if normalizer is None: + raise UnknownNormalizerSource( + f"Unsupported integration source: {normalized_source or ''}" + ) + return normalizer + + +def normalize_for_source( + source: str, + payload: Mapping[str, Any], + *, + headers: Optional[Mapping[str, Any]] = None, + route_config: Optional[Mapping[str, Any]] = None, + copy_payload: bool = False, +) -> Any: + """Normalize a payload through the registered source adapter. + + Production ingestion may pass validated payloads directly. Simulation sets + ``copy_payload=True`` to guarantee that a normalizer cannot mutate the + caller's input. + """ + if not isinstance(payload, Mapping): + raise TypeError("payload must be an object") + + source_key = str(source or "").strip().lower() + normalizer = get_normalizer(source_key) + normalized_payload = copy.deepcopy(dict(payload)) if copy_payload else payload + + return normalizer( + normalized_payload, + dict(headers or {}), + dict(route_config or {}), + ) + + +__all__ = [ + "SUPPORTED_NORMALIZER_SOURCES", + "UnknownNormalizerSource", + "get_normalizer", + "normalize_for_source", +] diff --git a/app/services/orchestration/runtime.py b/app/services/orchestration/runtime.py index 90417b2..3abd14f 100644 --- a/app/services/orchestration/runtime.py +++ b/app/services/orchestration/runtime.py @@ -25,7 +25,7 @@ from app.services.orchestration.cache import published_definition_cache from app.services.orchestration.engine import execute_rule_tree from app.services.orchestration.fields import build_context -from app.modules.redaction import redact_secrets +from app.services.orchestration.safety import safe_trace_value logger = logging.getLogger("oncall.orchestration.runtime") @@ -120,39 +120,6 @@ def to_dict(self): } -def _bounded_trace_value(value: Any, *, depth: int = 0) -> Any: - """Return a bounded, JSON-compatible value for execution traces.""" - if depth > 12: - return "" - - if isinstance(value, str): - return value if len(value) <= 2048 else value[:2048] + "…" - - if value is None or isinstance(value, (int, float, bool)): - return value - - if isinstance(value, Mapping): - return { - str(key): _bounded_trace_value(item, depth=depth + 1) - for key, item in list(value.items())[:512] - } - - if isinstance(value, (list, tuple, set)): - return [ - _bounded_trace_value(item, depth=depth + 1) - for item in list(value)[:512] - ] - - if isinstance(value, BaseException): - return str(value) - - return str(value) - - -def _safe_trace_value(value: Any) -> Any: - """Bound trace data and redact secrets using the project-wide helper.""" - return redact_secrets(_bounded_trace_value(value)) - def _entity_context(entity) -> Dict[str, Any]: if entity is None: @@ -308,9 +275,9 @@ def _record_execution( "applied": bool(applied), "mode": orchestration.mode, "compatibility_mode": orchestration.compatibility_mode, - "initial_context": _safe_trace_value(initial_context or {}), - "result": _safe_trace_value(result.to_dict()) if result else None, - "error": _safe_trace_value(error), + "initial_context": safe_trace_value(initial_context or {}), + "result": safe_trace_value(result.to_dict()) if result else None, + "error": safe_trace_value(error), } disposition = None matched = 0 @@ -430,7 +397,7 @@ def _mark_execution_rejected(step: RuntimeStep, reason: str) -> RuntimeStep: execution = OrchestrationExecution.get_by_id(step.execution_id) trace_json = copy.deepcopy(execution.trace_json or {}) trace_json["applied"] = False - trace_json["rejected_reason"] = _safe_trace_value(reason) + trace_json["rejected_reason"] = safe_trace_value(reason) execution.trace_json = trace_json execution.save(only=[OrchestrationExecution.trace_json]) except Exception: @@ -707,6 +674,40 @@ def _apply_runtime_to_alert_data( alert_data["priority_set_by_orchestration"] = True +def _runtime_explain_payload(result: RuntimeResult) -> Dict[str, Any]: + """Return runtime summary plus full redacted rule traces for Explain.""" + payload = result.to_dict() + execution_ids = result.execution_ids + if not execution_ids: + payload["executions"] = [] + return payload + + rows = { + row.id: row + for row in OrchestrationExecution.select().where( + OrchestrationExecution.id.in_(execution_ids) + ) + } + executions = [] + for step in result.steps: + row = rows.get(step.execution_id) + if row is None: + continue + executions.append( + { + "execution_id": row.id, + "orchestration_id": row.orchestration_id, + "version_id": row.version_id, + "duration_ms": row.duration_ms, + "matched_rule_count": row.matched_rule_count, + "disposition": row.disposition, + "trace": safe_trace_value(row.trace_json or {}), + } + ) + payload["executions"] = executions + return payload + + def _trace_runtime(trace, result: RuntimeResult, *, phase="global"): if trace is None: return @@ -726,7 +727,7 @@ def _trace_runtime(trace, result: RuntimeResult, *, phase="global"): ), result.reason, phase=phase, - orchestration=result.to_dict(), + orchestration=_runtime_explain_payload(result), ) @@ -901,6 +902,32 @@ def run_service_orchestration( return runtime +def _actual_result_snapshot(*, group=None, alert=None) -> Dict[str, Any]: + """Capture the lifecycle result used to compare a shadow candidate.""" + route_id = getattr(alert, "route_id", None) or getattr(group, "route_id", None) + team_id = getattr(alert, "team_id", None) or getattr(group, "team_id", None) + service_id = getattr(alert, "service_id", None) or getattr(group, "service_id", None) + suppressed = bool( + getattr(alert, "orchestration_suppressed", False) + or getattr(group, "orchestration_suppressed", False) + ) + return { + "route_id": route_id, + "team_id": team_id, + "service_id": service_id, + "severity": getattr(alert, "severity", None), + "title": getattr(alert, "title", None), + "group_key": ( + getattr(alert, "group_key", None) + or getattr(group, "group_key", None) + ), + "status": getattr(alert, "status", None) or getattr(group, "status", None), + "disposition": "suppress" if suppressed else "process", + "alert_id": getattr(alert, "id", None), + "alert_group_id": getattr(group, "id", None), + } + + def attach_runtime_executions( runtime: Optional[RuntimeResult], *, @@ -915,6 +942,17 @@ def attach_runtime_executions( alert_id=getattr(alert, "id", None), ).where(OrchestrationExecution.id.in_(execution_ids)).execute() + actual_result = safe_trace_value( + _actual_result_snapshot(group=group, alert=alert) + ) + for execution in OrchestrationExecution.select().where( + OrchestrationExecution.id.in_(execution_ids) + ): + trace_json = copy.deepcopy(execution.trace_json or {}) + trace_json["actual_result"] = actual_result + execution.trace_json = trace_json + execution.save(only=[OrchestrationExecution.trace_json]) + # Queue outbound automation only after the orchestration decision and the # lifecycle outcome have been persisted. The queue function is idempotent. from app.services.orchestration.webhooks import enqueue_execution_webhooks diff --git a/app/services/orchestration/safety.py b/app/services/orchestration/safety.py new file mode 100644 index 0000000..8b76f66 --- /dev/null +++ b/app/services/orchestration/safety.py @@ -0,0 +1,176 @@ +"""Shared safety helpers for orchestration traces and JSON payloads.""" + +from __future__ import annotations + +import json +from collections.abc import Mapping +from typing import Any, Optional + +from app.modules.redaction import redact_secrets +from app.settings import Config + + +class OrchestrationJsonError(ValueError): + """Raised when orchestration input cannot be represented safely as JSON.""" + + +def _positive_int(value: Any, default: int, *, minimum: int = 1) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(minimum, parsed) + + +def trace_limits() -> tuple[int, int, int]: + """Return the configured depth, string and collection trace limits.""" + return ( + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_DEPTH", 12), + 12, + minimum=0, + ), + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_STRING_CHARS", 2048), + 2048, + ), + _positive_int( + getattr(Config, "ORCHESTRATION_TRACE_MAX_ITEMS", 512), + 512, + ), + ) + + +def bounded_trace_value( + value: Any, + *, + depth: int = 0, + max_depth: Optional[int] = None, + max_string_chars: Optional[int] = None, + max_items: Optional[int] = None, +) -> Any: + """Return a bounded JSON-compatible representation of ``value``.""" + if max_depth is None or max_string_chars is None or max_items is None: + configured_depth, configured_string, configured_items = trace_limits() + max_depth = configured_depth if max_depth is None else max_depth + max_string_chars = ( + configured_string + if max_string_chars is None + else max_string_chars + ) + max_items = configured_items if max_items is None else max_items + + max_depth = max(0, int(max_depth)) + max_string_chars = max(1, int(max_string_chars)) + max_items = max(1, int(max_items)) + + if depth > max_depth: + return "" + + if isinstance(value, str): + if len(value) <= max_string_chars: + return value + return value[:max_string_chars] + "…" + + if value is None or isinstance(value, (int, float, bool)): + return value + + if isinstance(value, Mapping): + return { + str(key): bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for key, item in list(value.items())[:max_items] + } + + if isinstance(value, (list, tuple)): + return [ + bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for item in value[:max_items] + ] + + if isinstance(value, (set, frozenset)): + ordered = sorted(value, key=lambda item: repr(item)) + return [ + bounded_trace_value( + item, + depth=depth + 1, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + for item in ordered[:max_items] + ] + + if isinstance(value, BaseException): + return bounded_trace_value( + str(value), + depth=depth, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + + return bounded_trace_value( + str(value), + depth=depth, + max_depth=max_depth, + max_string_chars=max_string_chars, + max_items=max_items, + ) + + +def safe_trace_value(value: Any) -> Any: + """Bound trace data and redact secrets through the global redactor.""" + return redact_secrets(bounded_trace_value(value)) + + +def json_size_bytes(value: Any) -> int: + """Return deterministic UTF-8 JSON size or raise ``OrchestrationJsonError``.""" + try: + encoded = json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + default=str, + ).encode("utf-8") + except (TypeError, ValueError) as exc: + raise OrchestrationJsonError("value must be JSON-compatible") from exc + return len(encoded) + + +def ensure_json_size( + value: Any, + *, + maximum_bytes: int, + label: str = "Value", +) -> int: + """Validate a JSON value against a byte limit and return its size.""" + maximum = max(1, int(maximum_bytes)) + size = json_size_bytes(value) + if size > maximum: + raise OrchestrationJsonError( + f"{label} exceeds the {maximum}-byte limit" + ) + return size + + +__all__ = [ + "OrchestrationJsonError", + "bounded_trace_value", + "ensure_json_size", + "json_size_bytes", + "safe_trace_value", + "trace_limits", +] diff --git a/app/services/orchestration/simulator.py b/app/services/orchestration/simulator.py new file mode 100644 index 0000000..48c945b --- /dev/null +++ b/app/services/orchestration/simulator.py @@ -0,0 +1,969 @@ +"""Safe Event Orchestration simulation, replay and shadow analytics. + +The simulator evaluates isolated event copies. It never calls the alert +lifecycle, persists orchestration executions, creates pending events, or +queues webhook actions. +""" + +from __future__ import annotations + +import copy +import time +from dataclasses import dataclass +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from app.modules.common import parse_datetime, utc_now +from app.modules.db import orchestrations_repo +from app.modules.db.models import ( + Alert, + EventOrchestration, + EventOrchestrationVersion, + OrchestrationExecution, +) +from app.services.integrations.normalizers.registry import ( + SUPPORTED_NORMALIZER_SOURCES, + UnknownNormalizerSource, + normalize_for_source, +) +from app.services.orchestration.engine import execute_rule_tree +from app.services.orchestration.runtime import ( + RuntimeOrchestrationError, + _build_runtime_context, + _initial_entities, + _resolve_selected_entities, +) +from app.services.orchestration.safety import ( + OrchestrationJsonError, + ensure_json_size, + safe_trace_value, +) +from app.settings import Config + + +SUPPORTED_SIMULATION_SOURCES = SUPPORTED_NORMALIZER_SOURCES + + +class OrchestrationSimulationError(ValueError): + """Base error for invalid or inaccessible simulation inputs.""" + + +class OrchestrationSimulationNotFound(OrchestrationSimulationError): + pass + + +class OrchestrationSimulationConflict(OrchestrationSimulationError): + pass + + +@dataclass(frozen=True) +class ReplayInput: + kind: str + id: int + event: Dict[str, Any] + + + + +def _iso_datetime(value: Any) -> Optional[str]: + parsed = parse_datetime(value) + return parsed.isoformat() if parsed is not None else None + + +def _ensure_payload_size(value: Any, *, label: str) -> None: + maximum = max( + 1024, + int( + getattr( + Config, + "ORCHESTRATION_SIMULATION_MAX_PAYLOAD_BYTES", + 1048576, + ) + ), + ) + try: + ensure_json_size(value, maximum_bytes=maximum, label=label) + except OrchestrationJsonError as exc: + message = str(exc) + if " exceeds the " in message: + message = f"{label} exceeds the {maximum}-byte simulation limit" + raise OrchestrationSimulationError(message) from exc + + +def get_orchestration(orchestration_id: int) -> EventOrchestration: + row = EventOrchestration.get_or_none( + (EventOrchestration.id == orchestration_id) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if row is None: + raise OrchestrationSimulationNotFound("Orchestration not found") + return row + + +def get_simulation_version( + orchestration: EventOrchestration, + version_id: Optional[int] = None, +) -> EventOrchestrationVersion: + if version_id is not None: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == int(version_id) + ) + if version is None or version.orchestration_id != orchestration.id: + raise OrchestrationSimulationNotFound( + "Orchestration version not found" + ) + return version + + draft = orchestrations_repo.get_draft(orchestration.id) + if draft is not None: + return draft + + if orchestration.active_version_id is not None: + version = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == orchestration.active_version_id + ) + if version is not None and version.orchestration_id == orchestration.id: + return version + + raise OrchestrationSimulationConflict( + "Orchestration has no draft or published version to simulate" + ) + + +def normalize_simulation_payload( + *, + source: str, + payload: Any, + headers: Optional[Mapping[str, Any]] = None, + event_index: int = 0, +) -> Tuple[str, Dict[str, Any], int]: + source = str(source or "").strip().lower() + if source not in SUPPORTED_SIMULATION_SOURCES: + raise OrchestrationSimulationError( + "Unsupported simulation source: " + (source or "missing") + ) + if not isinstance(payload, Mapping): + raise OrchestrationSimulationError("Simulation payload must be an object") + _ensure_payload_size(payload, label="Simulation payload") + + try: + events = normalize_for_source( + source, + payload, + headers=headers, + route_config={}, + copy_payload=True, + ) + except UnknownNormalizerSource as exc: + raise OrchestrationSimulationError(str(exc)) from exc + except Exception as exc: + raise OrchestrationSimulationError( + f"{source} payload could not be normalized" + ) from exc + + if isinstance(events, Mapping): + events = [events] + if not isinstance(events, list) or not events: + raise OrchestrationSimulationError( + "The selected normalizer produced no events" + ) + if event_index < 0 or event_index >= len(events): + raise OrchestrationSimulationError( + f"event_index must be between 0 and {len(events) - 1}" + ) + + event = events[event_index] + if not isinstance(event, Mapping): + raise OrchestrationSimulationError( + "The selected normalizer produced an invalid event" + ) + normalized = copy.deepcopy(dict(event)) + normalized.setdefault("source", source) + _ensure_payload_size(normalized, label="Normalized event") + _validate_normalized_event(normalized) + return source, normalized, len(events) + + +def prepare_normalized_event(event: Mapping[str, Any]) -> Dict[str, Any]: + if not isinstance(event, Mapping): + raise OrchestrationSimulationError("normalized_event must be an object") + normalized = copy.deepcopy(dict(event)) + _ensure_payload_size(normalized, label="Normalized event") + _validate_normalized_event(normalized) + return normalized + + +def _validate_normalized_event(event: Mapping[str, Any]) -> None: + missing = [ + key + for key in ("source", "dedup_key", "title") + if event.get(key) in (None, "") + ] + if missing: + raise OrchestrationSimulationError( + "Normalized event is missing: " + ", ".join(missing) + ) + labels = event.get("labels") + if labels is not None and not isinstance(labels, Mapping): + raise OrchestrationSimulationError( + "normalized_event.labels must be an object" + ) + + +def _entity_summary(value: Any) -> Optional[Dict[str, Any]]: + if value is None: + return None + return { + "id": getattr(value, "id", None), + "name": getattr(value, "name", None), + "slug": getattr(value, "slug", None), + } + + +def _selected_summary( + context: Mapping[str, Any], + *, + orchestration: EventOrchestration, + event: Mapping[str, Any], + initial_route: Any, + initial_team: Any, + initial_service: Any, +) -> Tuple[Dict[str, Any], List[str]]: + errors: List[str] = [] + try: + ( + route, + team, + service, + escalation, + priority, + notification, + grouping, + ) = _resolve_selected_entities( + context, + group_id=orchestration.group_id, + source=event.get("source"), + fallback_route=initial_route, + fallback_team=initial_team, + fallback_service=initial_service, + ) + except RuntimeOrchestrationError as exc: + errors.append(str(exc)) + result = context.get("result") or {} + return { + "routing": copy.deepcopy(result.get("routing") or {}), + "policies": copy.deepcopy(result.get("policies") or {}), + "grouping": copy.deepcopy(result.get("grouping") or {}), + }, errors + + return { + "route": _entity_summary(route), + "team": _entity_summary(team), + "service": _entity_summary(service), + "escalation_policy": _entity_summary(escalation), + "priority_policy": _entity_summary(priority), + "notification_policy": _entity_summary(notification), + "grouping": copy.deepcopy(grouping or {}), + }, errors + + +def _disposition(context: Mapping[str, Any]) -> Dict[str, Any]: + result = context.get("result") or {} + disposition = result.get("disposition") or "process" + reason = None + if disposition == "drop": + reason = result.get("drop_reason") + elif disposition == "suppress": + reason = result.get("suppress_reason") + elif disposition == "pause": + reason = result.get("pause_reason") + return { + "type": disposition, + "reason": reason, + "pause_seconds": result.get("pause_seconds"), + "pause_retrigger": result.get("pause_retrigger"), + } + + +def _flatten( + value: Any, + *, + path: str = "", + output: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + if output is None: + output = {} + if isinstance(value, Mapping): + if not value: + output[path or "$"] = {} + for key in sorted(value, key=lambda item: str(item)): + child = f"{path}.{key}" if path else str(key) + _flatten(value[key], path=child, output=output) + return output + if isinstance(value, list): + if not value: + output[path or "$"] = [] + for index, item in enumerate(value): + child = f"{path}[{index}]" if path else f"[{index}]" + _flatten(item, path=child, output=output) + return output + output[path or "$"] = value + return output + + +def context_diff( + before: Mapping[str, Any], + after: Mapping[str, Any], + *, + limit: Optional[int] = None, +) -> Dict[str, Any]: + maximum = min( + max( + int( + limit + or getattr(Config, "ORCHESTRATION_SIMULATION_MAX_DIFFS", 512) + ), + 1, + ), + 5000, + ) + left = _flatten(before) + right = _flatten(after) + changes = [] + for path in sorted(set(left) | set(right)): + old = left.get(path) + new = right.get(path) + if old == new: + continue + changes.append( + { + "path": path, + "before": safe_trace_value(old), + "after": safe_trace_value(new), + } + ) + if len(changes) >= maximum: + break + total = sum( + 1 + for path in set(left) | set(right) + if left.get(path) != right.get(path) + ) + return { + "changed": bool(total), + "total_changes": total, + "truncated": total > len(changes), + "changes": changes, + } + + +def _simulate_version( + orchestration: EventOrchestration, + version: EventOrchestrationVersion, + event: Mapping[str, Any], + *, + evaluated_at: str, +) -> Dict[str, Any]: + validation = orchestrations_repo.validate_version(version.id) + response: Dict[str, Any] = { + "orchestration_id": orchestration.id, + "version_id": version.id, + "version_number": version.version_number, + "version_status": version.status, + "validation": validation, + } + if not validation.get("valid"): + response["executed"] = False + response["errors"] = list(validation.get("errors") or []) + return response + + normalized = prepare_normalized_event(event) + try: + route, team, service = _initial_entities( + normalized, + orchestration.group_id, + ) + except RuntimeOrchestrationError as exc: + response.update( + { + "executed": False, + "errors": [str(exc)], + "initial_normalized_event": safe_trace_value(normalized), + } + ) + return response + + if service is None and orchestration.scope == "service": + service = orchestration.service + if team is None and service is not None: + team = service.team + + context = _build_runtime_context( + normalized, + route=route, + team=team, + service=service, + ) + context.setdefault("time", {})["now"] = evaluated_at + definition = orchestrations_repo.export_version(version.id) + started = time.perf_counter() + try: + result = execute_rule_tree(definition.get("rules") or [], context) + except Exception as exc: + response.update( + { + "executed": False, + "errors": [exc.__class__.__name__], + "initial_normalized_event": safe_trace_value(normalized), + "initial_context": safe_trace_value(context), + } + ) + return response + + duration_ms = max(0, int((time.perf_counter() - started) * 1000)) + selected, selection_errors = _selected_summary( + result.context, + orchestration=orchestration, + event=normalized, + initial_route=route, + initial_team=team, + initial_service=service, + ) + response.update( + { + "executed": True, + "duration_ms": duration_ms, + "initial_normalized_event": safe_trace_value(normalized), + "initial_context": safe_trace_value(context), + "execution": safe_trace_value(result.to_dict()), + "final_context": safe_trace_value(result.context), + "selected": safe_trace_value(selected), + "disposition": safe_trace_value(_disposition(result.context)), + "errors": selection_errors, + } + ) + return response + + +def _simulate_resolved_version( + orchestration: EventOrchestration, + version: EventOrchestrationVersion, + event: Mapping[str, Any], + *, + active_version: Optional[EventOrchestrationVersion] = None, + compare_with_active: bool = False, + selected_normalizer: str = "normalized", + normalized_event_count: int = 1, + evaluated_at: Optional[str] = None, +) -> Dict[str, Any]: + evaluated_at = evaluated_at or utc_now().isoformat() + result = _simulate_version( + orchestration, + version, + event, + evaluated_at=evaluated_at, + ) + result["evaluated_at"] = evaluated_at + result["selected_normalizer"] = selected_normalizer + result["normalized_event_count"] = normalized_event_count + + if compare_with_active and active_version is not None: + active_result = _simulate_version( + orchestration, + active_version, + event, + evaluated_at=evaluated_at, + ) + active_result["evaluated_at"] = evaluated_at + result["active"] = active_result + if result.get("executed") and active_result.get("executed"): + result["active_draft_diff"] = context_diff( + active_result.get("final_context") or {}, + result.get("final_context") or {}, + ) + return safe_trace_value(result) + + +def _active_version( + orchestration: EventOrchestration, +) -> Optional[EventOrchestrationVersion]: + if orchestration.active_version_id is None: + return None + active = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == orchestration.active_version_id + ) + if active is None or active.orchestration_id != orchestration.id: + return None + return active + + +def simulate_event( + orchestration_id: int, + event: Mapping[str, Any], + *, + version_id: Optional[int] = None, + compare_with_active: bool = False, + selected_normalizer: str = "normalized", + normalized_event_count: int = 1, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + version = get_simulation_version(orchestration, version_id) + active = _active_version(orchestration) if compare_with_active else None + return _simulate_resolved_version( + orchestration, + version, + event, + active_version=active, + compare_with_active=compare_with_active, + selected_normalizer=selected_normalizer, + normalized_event_count=normalized_event_count, + ) + + +def _entity_group_id( + value: Any, + *, + depth: int = 0, + seen: Optional[set[int]] = None, +) -> Optional[int]: + """Resolve a group through team/route/service relationships safely.""" + if value is None or depth > 3: + return None + seen = seen or set() + identity = id(value) + if identity in seen: + return None + seen.add(identity) + + group_id = getattr(value, "group_id", None) + if group_id is not None: + return int(group_id) + + for attribute in ("team", "route", "service", "group"): + try: + related = getattr(value, attribute, None) + except Exception: + continue + resolved = _entity_group_id( + related, + depth=depth + 1, + seen=seen, + ) + if resolved is not None: + return resolved + return None + + +def _alert_group_id(alert: Alert) -> Optional[int]: + # Alert.group_id points to AlertGroup, not to the tenant Group. Resolve + # ownership only through the alert's related routing/service entities. + for attribute in ("team", "route", "service", "group"): + try: + related = getattr(alert, attribute, None) + except Exception: + continue + resolved = _entity_group_id(related) + if resolved is not None: + return resolved + return None + + +def event_from_alert(alert: Alert) -> Dict[str, Any]: + event = { + "source": alert.source, + "external_id": alert.external_id, + "dedup_key": alert.dedup_key, + "group_key": alert.group_key, + "title": alert.title, + "message": alert.message, + "severity": alert.severity, + "labels": copy.deepcopy(alert.labels or {}), + "payload": copy.deepcopy(alert.payload or {}), + "status": alert.status, + } + if alert.route_id: + event["forced_route_id"] = alert.route_id + if alert.team_id: + event["forced_team_id"] = alert.team_id + if alert.service_id: + event["service_id"] = alert.service_id + return event + + +def event_from_execution(execution: OrchestrationExecution) -> Dict[str, Any]: + trace = execution.trace_json or {} + context = trace.get("initial_context") or {} + event = copy.deepcopy(context.get("event") or {}) + if context.get("raw") and not event.get("payload"): + event["payload"] = copy.deepcopy(context.get("raw")) + event.setdefault("source", execution.source or "webhook") + event.setdefault( + "dedup_key", + execution.event_fingerprint or f"execution:{execution.id}", + ) + event.setdefault("title", f"Replay execution {execution.id}") + event.setdefault("labels", {}) + return prepare_normalized_event(event) + + +def load_replay_inputs( + orchestration: EventOrchestration, + *, + alert_ids: Sequence[int] = (), + execution_ids: Sequence[int] = (), +) -> List[ReplayInput]: + maximum = min( + max( + int(getattr(Config, "ORCHESTRATION_REPLAY_MAX_EVENTS", 100)), + 1, + ), + 1000, + ) + identifiers = len(alert_ids) + len(execution_ids) + if identifiers < 1: + raise OrchestrationSimulationError( + "Replay requires at least one alert_id or execution_id" + ) + if identifiers > maximum: + raise OrchestrationSimulationError( + f"Replay is limited to {maximum} events" + ) + + inputs: List[ReplayInput] = [] + for alert_id in alert_ids: + alert = Alert.get_or_none(Alert.id == int(alert_id)) + if alert is None: + raise OrchestrationSimulationNotFound( + f"Alert {alert_id} not found" + ) + if _alert_group_id(alert) != orchestration.group_id: + raise OrchestrationSimulationError( + f"Alert {alert_id} belongs to another group" + ) + inputs.append(ReplayInput("alert", alert.id, event_from_alert(alert))) + + for execution_id in execution_ids: + execution = OrchestrationExecution.get_or_none( + OrchestrationExecution.id == int(execution_id) + ) + if execution is None: + raise OrchestrationSimulationNotFound( + f"Execution {execution_id} not found" + ) + if execution.group_id != orchestration.group_id: + raise OrchestrationSimulationError( + f"Execution {execution_id} belongs to another group" + ) + inputs.append( + ReplayInput( + "execution", + execution.id, + event_from_execution(execution), + ) + ) + return inputs + + +def replay_events( + orchestration_id: int, + *, + alert_ids: Sequence[int] = (), + execution_ids: Sequence[int] = (), + version_id: Optional[int] = None, + compare_with_active: bool = False, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + inputs = load_replay_inputs( + orchestration, + alert_ids=alert_ids, + execution_ids=execution_ids, + ) + version = get_simulation_version(orchestration, version_id) + active = _active_version(orchestration) if compare_with_active else None + evaluated_at = utc_now().isoformat() + results = [] + for item in inputs: + try: + simulation = _simulate_resolved_version( + orchestration, + version, + item.event, + active_version=active, + compare_with_active=compare_with_active, + selected_normalizer="stored_normalized_event", + evaluated_at=evaluated_at, + ) + results.append( + { + "input": {"kind": item.kind, "id": item.id}, + "ok": True, + "simulation": simulation, + } + ) + except Exception as exc: + results.append( + { + "input": {"kind": item.kind, "id": item.id}, + "ok": False, + "error": exc.__class__.__name__, + } + ) + successful = [item for item in results if item["ok"]] + dispositions: Dict[str, int] = {} + changed_from_active = 0 + for item in successful: + simulation = item.get("simulation") or {} + disposition = (simulation.get("disposition") or {}).get("type") or "process" + dispositions[disposition] = dispositions.get(disposition, 0) + 1 + if (simulation.get("active_draft_diff") or {}).get("changed"): + changed_from_active += 1 + + drop_count = dispositions.get("drop", 0) + drop_percentage = ( + round((drop_count / len(successful)) * 100, 2) + if successful + else 0.0 + ) + warning_threshold = min( + max( + int( + getattr( + Config, + "ORCHESTRATION_REPLAY_DROP_WARNING_PERCENT", + 20, + ) + ), + 0, + ), + 100, + ) + warnings = [] + if successful and drop_percentage >= warning_threshold and drop_count: + warnings.append( + { + "code": "high_drop_rate", + "message": ( + f"Draft would drop {drop_percentage}% of replayed events" + ), + "drop_percentage": drop_percentage, + "threshold_percentage": warning_threshold, + } + ) + + return { + "orchestration_id": orchestration.id, + "version_id": version.id, + "active_version_id": active.id if active is not None else None, + "evaluated_at": evaluated_at, + "count": len(results), + "successful": len(successful), + "failed": sum(1 for item in results if not item["ok"]), + "production_state_modified": False, + "summary": { + "dispositions": dispositions, + "drop_percentage": drop_percentage, + "changed_from_active": changed_from_active, + }, + "warnings": warnings, + "results": results, + } + + +def serialize_execution( + execution: OrchestrationExecution, + *, + include_trace: bool = False, +) -> Dict[str, Any]: + created_at = execution.created_at + expires_at = execution.expires_at + trace = execution.trace_json or {} + result = { + "id": execution.id, + "uid": str(execution.uid), + "group_id": execution.group_id, + "orchestration_id": execution.orchestration_id, + "version_id": execution.version_id, + "source": execution.source, + "integration_name": execution.integration_name, + "event_fingerprint": execution.event_fingerprint, + "mode": trace.get("mode"), + "compatibility_mode": trace.get("compatibility_mode"), + "applied": bool(trace.get("applied")), + "error": safe_trace_value(trace.get("error")), + "rejected_reason": safe_trace_value(trace.get("rejected_reason")), + "disposition": execution.disposition, + "matched_rule_count": execution.matched_rule_count, + "duration_ms": execution.duration_ms, + "alert_id": execution.alert_id, + "alert_group_id": execution.alert_group_id, + "created_at": _iso_datetime(created_at), + "expires_at": _iso_datetime(expires_at), + } + if include_trace: + result["trace"] = safe_trace_value(execution.trace_json or {}) + return result + + +def list_executions( + orchestration_id: int, + *, + limit: int = 50, + include_trace: bool = False, +) -> List[Dict[str, Any]]: + orchestration = get_orchestration(orchestration_id) + maximum = min(max(int(limit), 1), 200) + rows = ( + OrchestrationExecution.select() + .where(OrchestrationExecution.orchestration == orchestration.id) + .order_by(OrchestrationExecution.id.desc()) + .limit(maximum) + ) + return [ + serialize_execution(row, include_trace=include_trace) + for row in rows + ] + + +def _candidate_actual_values(trace: Mapping[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]: + result = (trace.get("result") or {}).get("context") or {} + candidate_result = result.get("result") or {} + candidate_routing = candidate_result.get("routing") or {} + candidate_event = result.get("event") or {} + candidate = { + "route_id": ( + candidate_routing.get("route_id") + or (result.get("route") or {}).get("id") + ), + "team_id": ( + candidate_routing.get("team_id") + or (result.get("team") or {}).get("id") + ), + "service_id": ( + candidate_routing.get("service_id") + or (result.get("service") or {}).get("id") + ), + "severity": candidate_event.get("severity"), + "title": candidate_event.get("title"), + "group_key": ( + (candidate_result.get("grouping") or {}).get("group_key") + or candidate_event.get("group_key") + ), + "disposition": candidate_result.get("disposition") or "process", + } + actual = copy.deepcopy(trace.get("actual_result") or {}) + return candidate, actual + + +def shadow_metrics( + orchestration_id: int, + *, + limit: Optional[int] = None, +) -> Dict[str, Any]: + orchestration = get_orchestration(orchestration_id) + maximum = min( + max( + int( + limit + or getattr( + Config, + "ORCHESTRATION_SHADOW_METRICS_MAX_EXECUTIONS", + 5000, + ) + ), + 1, + ), + 10000, + ) + rows = ( + OrchestrationExecution.select() + .where(OrchestrationExecution.orchestration == orchestration.id) + .order_by(OrchestrationExecution.id.desc()) + .limit(maximum) + ) + metrics = { + "executions": 0, + "matched": 0, + "errors": 0, + "rejected": 0, + "comparable_executions": 0, + "not_comparable": 0, + "routing_changes": 0, + "team_changes": 0, + "service_changes": 0, + "severity_changes": 0, + "title_changes": 0, + "grouping_changes": 0, + "potential_drops": 0, + "potential_suppressions": 0, + "potential_pauses": 0, + } + first_at = None + last_at = None + for row in rows: + trace = row.trace_json or {} + if trace.get("mode") != "shadow": + continue + metrics["executions"] += 1 + if row.matched_rule_count: + metrics["matched"] += 1 + execution_error = trace.get("error") + rejected_reason = trace.get("rejected_reason") + if execution_error: + metrics["errors"] += 1 + if rejected_reason: + metrics["rejected"] += 1 + + candidate, actual = _candidate_actual_values(trace) + comparable = bool(trace.get("result")) and not execution_error and not rejected_reason + if actual and comparable: + metrics["comparable_executions"] += 1 + if candidate.get("route_id") != actual.get("route_id"): + metrics["routing_changes"] += 1 + if candidate.get("team_id") != actual.get("team_id"): + metrics["team_changes"] += 1 + if candidate.get("service_id") != actual.get("service_id"): + metrics["service_changes"] += 1 + if candidate.get("severity") != actual.get("severity"): + metrics["severity_changes"] += 1 + if candidate.get("title") != actual.get("title"): + metrics["title_changes"] += 1 + if candidate.get("group_key") != actual.get("group_key"): + metrics["grouping_changes"] += 1 + else: + metrics["not_comparable"] += 1 + disposition = candidate.get("disposition") + if disposition == "drop": + metrics["potential_drops"] += 1 + elif disposition == "suppress": + metrics["potential_suppressions"] += 1 + elif disposition == "pause": + metrics["potential_pauses"] += 1 + created_at = parse_datetime(row.created_at) + if created_at: + first_at = min(first_at, created_at) if first_at else created_at + last_at = max(last_at, created_at) if last_at else created_at + + return { + "orchestration_id": orchestration.id, + "mode": orchestration.mode, + "limit": maximum, + "first_execution_at": first_at.isoformat() if first_at else None, + "last_execution_at": last_at.isoformat() if last_at else None, + "metrics": metrics, + } + + +__all__ = [ + "SUPPORTED_SIMULATION_SOURCES", + "OrchestrationSimulationConflict", + "OrchestrationSimulationError", + "OrchestrationSimulationNotFound", + "context_diff", + "get_orchestration", + "list_executions", + "normalize_simulation_payload", + "prepare_normalized_event", + "replay_events", + "shadow_metrics", + "simulate_event", +] diff --git a/app/settings.py b/app/settings.py index 7656bef..ff6ff03 100644 --- a/app/settings.py +++ b/app/settings.py @@ -194,6 +194,30 @@ class Config: ORCHESTRATION_WEBHOOK_EXECUTION_RETENTION_DAYS = settings.get_int( "orchestration", "webhook_execution_retention_days", 30 ) + ORCHESTRATION_REPLAY_MAX_EVENTS = settings.get_int( + "orchestration", "replay_max_events", 100 + ) + ORCHESTRATION_REPLAY_DROP_WARNING_PERCENT = settings.get_int( + "orchestration", "replay_drop_warning_percent", 20 + ) + ORCHESTRATION_TRACE_MAX_DEPTH = settings.get_int( + "orchestration", "trace_max_depth", 12 + ) + ORCHESTRATION_TRACE_MAX_STRING_CHARS = settings.get_int( + "orchestration", "trace_max_string_chars", 2048 + ) + ORCHESTRATION_TRACE_MAX_ITEMS = settings.get_int( + "orchestration", "trace_max_items", 512 + ) + ORCHESTRATION_SIMULATION_MAX_PAYLOAD_BYTES = settings.get_int( + "orchestration", "simulation_max_payload_bytes", 1048576 + ) + ORCHESTRATION_SIMULATION_MAX_DIFFS = settings.get_int( + "orchestration", "simulation_max_diffs", 512 + ) + ORCHESTRATION_SHADOW_METRICS_MAX_EXECUTIONS = settings.get_int( + "orchestration", "shadow_metrics_max_executions", 5000 + ) USER_NOTIFICATION_RULES_CHECK_INTERVAL_SECONDS = settings.get_int( "scheduler", "user_notification_rules_check_interval_seconds", diff --git a/app/views/integrations_view.py b/app/views/integrations_view.py index 7542d99..2f87ea3 100644 --- a/app/views/integrations_view.py +++ b/app/views/integrations_view.py @@ -21,17 +21,8 @@ from app.services.alerts.actions import acknowledge_alert, resolve_alert from app.services.alerts.lifecycle import upsert_alert from app.services.integrations.auth import require_alert_token -from app.services.integrations.normalizers.sentry import normalize_sentry -from app.services.integrations.normalizers.webhook import ( - is_pagerduty_events_v2, - normalize_webhook, -) -from app.services.integrations.normalizers.zabbix import normalize_zabbix -from app.services.integrations.normalizers.alertmanager import normalize_alertmanager -from app.services.integrations.normalizers.librenms import normalize_librenms -from app.services.integrations.normalizers.grafana import normalize_grafana -from app.services.integrations.normalizers.datadog import normalize_datadog -from app.services.integrations.normalizers.rmon import normalize_rmon +from app.services.integrations.normalizers.registry import normalize_for_source +from app.services.integrations.normalizers.webhook import is_pagerduty_events_v2 from app.services.validation import make_error_response, validate_body from app.notifiers.voice.loader import create_voice_provider from app.modules.db.models import UserNotificationDelivery @@ -41,9 +32,6 @@ confirm_aws_sns_subscription, validate_aws_sns_message, ) -from app.services.integrations.normalizers.aws_sns import ( - normalize_aws_sns, -) from app.notifiers.slack.actions import ( SlackActionError, handle_slack_action, @@ -63,7 +51,9 @@ def alertmanager_webhook(): payload, error = validate_body(AlertmanagerWebhookSchema) if error: return error - return process_incoming_alerts(normalize_alertmanager(payload.model_dump())) + return process_incoming_alerts( + normalize_for_source("alertmanager", payload.model_dump()) + ) @integrations_bp.route("/grafana", methods=["POST"]) @@ -75,7 +65,7 @@ def grafana_webhook(): return error return process_incoming_alerts( - normalize_grafana(payload.model_dump()) + normalize_for_source("grafana", payload.model_dump()) ) @@ -98,7 +88,7 @@ def datadog_webhook(): return error return process_incoming_alerts( - normalize_datadog(payload.model_dump(exclude_none=True)) + normalize_for_source("datadog", payload.model_dump(exclude_none=True)) ) @@ -113,7 +103,7 @@ def rmon_webhook(): return error return process_incoming_alerts( - normalize_rmon(payload.model_dump()) + normalize_for_source("rmon", payload.model_dump()) ) @@ -248,7 +238,7 @@ def aws_sns_webhook(route_id): request.current_auth_type = "aws_sns_signature" return process_incoming_alerts( - normalize_aws_sns(envelope) + normalize_for_source("aws_sns", envelope) ) @@ -262,7 +252,9 @@ def zabbix_webhook(): payload, error = validate_body(ZabbixWebhookSchema) if error: return error - return process_incoming_alerts(normalize_zabbix(payload.model_dump())) + return process_incoming_alerts( + normalize_for_source("zabbix", payload.model_dump()) + ) def _pagerduty_events_success(dedup_key): @@ -344,7 +336,7 @@ def generic_webhook(): return error raw_payload = payload.model_dump() - normalized_alerts = normalize_webhook(raw_payload) + normalized_alerts = normalize_for_source("webhook", raw_payload) if is_pagerduty_events_v2(raw_payload): return _process_pagerduty_events_v2(normalized_alerts[0]) @@ -408,7 +400,8 @@ def sentry_webhook(route_id): request.current_auth_type = "sentry_signature" return process_incoming_alerts( - normalize_sentry( + normalize_for_source( + "sentry", payload.model_dump(), headers=dict(request.headers), route_config=route.integration_config or {}, @@ -426,7 +419,7 @@ def librenms_webhook(): return error return process_incoming_alerts( - normalize_librenms(payload.model_dump()) + normalize_for_source("librenms", payload.model_dump()) ) diff --git a/app/views/orchestrations_view.py b/app/views/orchestrations_view.py new file mode 100644 index 0000000..2a50d50 --- /dev/null +++ b/app/views/orchestrations_view.py @@ -0,0 +1,168 @@ +"""Event Orchestration simulator, replay and execution APIs.""" + +from flask import Blueprint, jsonify, request + +from app.api.schemas.orchestrations import ( + OrchestrationReplaySchema, + OrchestrationSimulationSchema, +) +from app.services.audit import write_audit +from app.services.orchestration import simulator +from app.services.orchestration.permissions import ( + REPLAY, + SIMULATE, + VIEW_EXECUTIONS, + has_orchestration_permission, +) +from app.services.rbac import current_user +from app.services.validation import make_error_response, validate_body + + +orchestrations_bp = Blueprint("event_orchestrations_api", __name__) + + +@orchestrations_bp.errorhandler(simulator.OrchestrationSimulationError) +def handle_orchestration_simulation_error(error): + if isinstance(error, simulator.OrchestrationSimulationNotFound): + code = "orchestration_not_found" + status = 404 + elif isinstance(error, simulator.OrchestrationSimulationConflict): + code = "orchestration_conflict" + status = 409 + else: + code = "orchestration_simulation_invalid" + status = 400 + return jsonify({"error": code, "message": str(error)}), status + + +def _require_permission(orchestration_id, permission): + orchestration = simulator.get_orchestration(orchestration_id) + user = current_user() + if user is None or not has_orchestration_permission( + user, + orchestration.group_id, + permission, + ): + return None, make_error_response( + "forbidden", + "You do not have permission to perform this orchestration action.", + 403, + ) + return orchestration, None + + +@orchestrations_bp.route("//simulate", methods=["POST"]) +def simulate_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, SIMULATE) + if error: + return error + + payload, error = validate_body(OrchestrationSimulationSchema) + if error: + return error + + if payload.normalized_event is not None: + event = simulator.prepare_normalized_event(payload.normalized_event) + selected_normalizer = "normalized" + normalized_event_count = 1 + else: + selected_normalizer, event, normalized_event_count = ( + simulator.normalize_simulation_payload( + source=payload.source, + payload=payload.payload, + headers=payload.headers, + event_index=payload.event_index, + ) + ) + + result = simulator.simulate_event( + orchestration.id, + event, + version_id=payload.version_id, + compare_with_active=payload.compare_with_active, + selected_normalizer=selected_normalizer, + normalized_event_count=normalized_event_count, + ) + + write_audit( + "event_orchestration.simulate", + object_type="event_orchestration", + object_id=orchestration.id, + group_id=orchestration.group_id, + data={ + "version_id": result.get("version_id"), + "source": event.get("source"), + "compare_with_active": payload.compare_with_active, + }, + ) + return jsonify(result) + + +@orchestrations_bp.route("//replay", methods=["POST"]) +def replay_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, REPLAY) + if error: + return error + + payload, error = validate_body(OrchestrationReplaySchema) + if error: + return error + + result = simulator.replay_events( + orchestration.id, + alert_ids=payload.alert_ids, + execution_ids=payload.execution_ids, + version_id=payload.version_id, + compare_with_active=payload.compare_with_active, + ) + write_audit( + "event_orchestration.replay", + object_type="event_orchestration", + object_id=orchestration.id, + group_id=orchestration.group_id, + data={ + "alert_ids": payload.alert_ids, + "execution_ids": payload.execution_ids, + "version_id": payload.version_id, + "count": result.get("count"), + }, + ) + return jsonify(result) + + +@orchestrations_bp.route("//executions", methods=["GET"]) +def list_orchestration_executions(orchestration_id): + orchestration, error = _require_permission( + orchestration_id, + VIEW_EXECUTIONS, + ) + if error: + return error + + limit = request.args.get("limit", default=50, type=int) + include_trace = request.args.get("include_trace") == "1" + return jsonify( + simulator.list_executions( + orchestration.id, + limit=limit, + include_trace=include_trace, + ) + ) + + +@orchestrations_bp.route("//shadow-metrics", methods=["GET"]) +def get_orchestration_shadow_metrics(orchestration_id): + orchestration, error = _require_permission( + orchestration_id, + VIEW_EXECUTIONS, + ) + if error: + return error + + limit = request.args.get("limit", default=None, type=int) + return jsonify( + simulator.shadow_metrics( + orchestration.id, + limit=limit, + ) + ) diff --git a/tests/conftest.py b/tests/conftest.py index 549b9ba..dd07e9e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -91,6 +91,11 @@ BusinessServiceStatusHistory, Heartbeat, HeartbeatPing, + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, PendingOrchestratedEvent, OrchestrationWebhookAction, AutomationExecution, @@ -101,6 +106,11 @@ AutomationExecution, OrchestrationWebhookAction, PendingOrchestratedEvent, + OrchestrationExecution, + EventOrchestrationRule, + OrchestrationIntakeToken, + EventOrchestrationVersion, + EventOrchestration, HeartbeatPing, AlertNotificationEvent, AlertNotification, diff --git a/tests/integrations/test_normalizer_registry.py b/tests/integrations/test_normalizer_registry.py new file mode 100644 index 0000000..732afee --- /dev/null +++ b/tests/integrations/test_normalizer_registry.py @@ -0,0 +1,83 @@ +import copy + +import pytest + +from app.services.integrations.normalizers import registry +from app.services.integrations.normalizers.registry import ( + SUPPORTED_NORMALIZER_SOURCES, + UnknownNormalizerSource, + normalize_for_source, +) + + +def test_registry_contains_all_supported_integration_normalizers(): + assert SUPPORTED_NORMALIZER_SOURCES == { + "alertmanager", + "aws_sns", + "datadog", + "grafana", + "librenms", + "rmon", + "sentry", + "webhook", + "zabbix", + } + + +def test_normalize_for_source_can_protect_caller_payload(): + payload = { + "alerts": [ + { + "status": "firing", + "fingerprint": "registry-1", + "labels": {"alertname": "DiskFull"}, + "annotations": {"summary": "Disk full"}, + } + ] + } + original = copy.deepcopy(payload) + + events = normalize_for_source( + "alertmanager", + payload, + copy_payload=True, + ) + + assert events[0]["dedup_key"] == "registry-1" + assert payload == original + + +def test_normalize_for_source_passes_sentry_context(monkeypatch): + captured = {} + + def fake_normalizer(payload, headers, route_config): + captured.update( + payload=payload, + headers=headers, + route_config=route_config, + ) + return [{"source": "sentry"}] + + monkeypatch.setitem( + registry._NORMALIZERS, + "sentry", + fake_normalizer, + ) + + result = normalize_for_source( + "sentry", + {"data": "event"}, + headers={"X-Test": "header"}, + route_config={"base_url": "https://sentry.example"}, + ) + + assert result == [{"source": "sentry"}] + assert captured["headers"] == {"X-Test": "header"} + assert captured["route_config"] == { + "base_url": "https://sentry.example" + } + + +def test_unknown_normalizer_source_is_rejected(): + with pytest.raises(UnknownNormalizerSource): + normalize_for_source("missing", {}) diff --git a/tests/orchestration/test_orchestration_safety.py b/tests/orchestration/test_orchestration_safety.py new file mode 100644 index 0000000..c463771 --- /dev/null +++ b/tests/orchestration/test_orchestration_safety.py @@ -0,0 +1,58 @@ +import pytest + +from app.services.orchestration.safety import ( + OrchestrationJsonError, + ensure_json_size, + safe_trace_value, +) +from app.settings import Config + + +def test_safe_trace_value_uses_global_redaction_and_configured_limits(monkeypatch): + monkeypatch.setattr(Config, "ORCHESTRATION_TRACE_MAX_DEPTH", 1, raising=False) + monkeypatch.setattr( + Config, + "ORCHESTRATION_TRACE_MAX_STRING_CHARS", + 4, + raising=False, + ) + monkeypatch.setattr(Config, "ORCHESTRATION_TRACE_MAX_ITEMS", 1, raising=False) + + value = safe_trace_value( + { + "token": "secret-token", + "message": "abcdefgh", + "nested": {"child": {"too": "deep"}}, + } + ) + + # Collection limiting happens before redaction and preserves insertion order. + assert value == {"token": "***REDACTED***"} + + +def test_safe_trace_value_truncates_strings_and_depth(monkeypatch): + monkeypatch.setattr(Config, "ORCHESTRATION_TRACE_MAX_DEPTH", 1, raising=False) + monkeypatch.setattr( + Config, + "ORCHESTRATION_TRACE_MAX_STRING_CHARS", + 4, + raising=False, + ) + monkeypatch.setattr(Config, "ORCHESTRATION_TRACE_MAX_ITEMS", 10, raising=False) + + value = safe_trace_value( + { + "message": "abcdefgh", + "nested": {"child": {"too": "deep"}}, + } + ) + + assert value["message"] == "abcd…" + assert value["nested"]["child"] == "" + + +def test_ensure_json_size_uses_utf8_byte_size(): + assert ensure_json_size("é", maximum_bytes=4) == 4 + + with pytest.raises(OrchestrationJsonError, match="exceeds"): + ensure_json_size("é", maximum_bytes=3, label="Payload") diff --git a/tests/orchestration/test_orchestration_simulator.py b/tests/orchestration/test_orchestration_simulator.py new file mode 100644 index 0000000..b42c8df --- /dev/null +++ b/tests/orchestration/test_orchestration_simulator.py @@ -0,0 +1,402 @@ +import copy + +import pytest + +from app.modules.db.models import ( + Alert, + AlertExplainStep, + AutomationExecution, + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + OrchestrationWebhookAction, + PendingOrchestratedEvent, +) +from app.modules.db.orchestrations_repo import ( + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, + set_runtime_state, +) +from app.services.alerts.lifecycle import upsert_alert +from app.services.orchestration.simulator import ( + OrchestrationSimulationError, + list_executions, + normalize_simulation_payload, + replay_events, + shadow_metrics, + simulate_event, +) +from app.services.orchestration.webhooks import create_webhook_action +from app.settings import Config +from tests.factories import ( + create_alert, + create_group, + create_route, + create_team, + create_user, +) + + +@pytest.fixture(autouse=True) +def orchestration_simulator_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + PendingOrchestratedEvent, + OrchestrationWebhookAction, + AutomationExecution, + ], + safe=True, + ) + AutomationExecution.delete().execute() + OrchestrationWebhookAction.delete().execute() + PendingOrchestratedEvent.delete().execute() + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _rule(title, *, extra_actions=None): + return [ + { + "name": title, + "condition_tree": {}, + "actions": [ + {"type": "set_title", "value": title}, + *(extra_actions or []), + ], + "processing_mode": "continue", + } + ] + + +def _published_orchestration(group, *, title="published", mode="active"): + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name=f"sim-{title}", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules(draft.id, _rule(title)) + published = publish_draft(orchestration.id, actor_id=user.id) + set_runtime_state( + orchestration.id, + enabled=True, + mode=mode, + compatibility_mode="hybrid", + ) + return orchestration, published, user + + +def _event(route, *, title="original", dedup="sim-1"): + return { + "source": route.source, + "forced_route_id": route.id, + "dedup_key": dedup, + "external_id": dedup, + "title": title, + "message": "message", + "severity": "warning", + "status": "firing", + "labels": {"environment": "prod"}, + "payload": {"token": "must-not-leak"}, + } + + +def test_simulate_draft_compares_with_active_without_persisting_state(db): + group = create_group() + team = create_team(group) + route = create_route(team) + orchestration, published, user = _published_orchestration(group) + + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + _rule( + "draft", + extra_actions=[ + {"type": "set_severity", "value": "critical"}, + {"type": "suppress", "reason": "maintenance candidate"}, + ], + ), + ) + + event = _event(route) + original = copy.deepcopy(event) + result = simulate_event( + orchestration.id, + event, + version_id=draft.id, + compare_with_active=True, + ) + + assert result["executed"] is True + assert result["version_id"] == draft.id + assert result["final_context"]["event"]["title"] == "draft" + assert result["active"]["version_id"] == published.id + assert result["active"]["final_context"]["event"]["title"] == "published" + assert result["active_draft_diff"]["changed"] is True + assert result["disposition"]["type"] == "suppress" + assert result["initial_context"]["raw"]["token"] == "***REDACTED***" + assert event == original + assert OrchestrationExecution.select().count() == 0 + assert PendingOrchestratedEvent.select().count() == 0 + assert AutomationExecution.select().count() == 0 + + +def test_raw_alertmanager_payload_reports_selected_normalizer(db): + source, event, count = normalize_simulation_payload( + source="alertmanager", + payload={ + "alerts": [ + { + "status": "firing", + "fingerprint": "fingerprint-1", + "labels": {"alertname": "DiskFull", "severity": "critical"}, + "annotations": {"summary": "Disk full"}, + } + ] + }, + ) + + assert source == "alertmanager" + assert count == 1 + assert event["dedup_key"] == "fingerprint-1" + assert event["title"] == "Disk full" + + +def test_replay_alert_and_execution_never_modify_production_state(db): + group = create_group() + team = create_team(group) + route = create_route(team) + orchestration, published, _ = _published_orchestration(group) + alert = create_alert(route) + execution = OrchestrationExecution.create( + group=group, + orchestration=orchestration, + version=published, + source="alertmanager", + event_fingerprint="execution-event", + matched_rule_count=0, + trace_json={ + "mode": "shadow", + "initial_context": { + "event": _event(route, dedup="execution-event"), + "raw": {}, + }, + }, + ) + + alert_count = Alert.select().count() + execution_count = OrchestrationExecution.select().count() + result = replay_events( + orchestration.id, + alert_ids=[alert.id], + execution_ids=[execution.id], + version_id=published.id, + ) + + assert result["count"] == 2 + assert result["successful"] == 2 + assert result["production_state_modified"] is False + assert Alert.select().count() == alert_count + assert OrchestrationExecution.select().count() == execution_count + assert AutomationExecution.select().count() == 0 + + +def test_shadow_execution_records_actual_result_metrics_and_full_explain(db): + group = create_group() + team = create_team(group) + route = create_route(team) + orchestration, _, _ = _published_orchestration( + group, + title="shadow-title", + mode="shadow", + ) + + result = upsert_alert(_event(route, title="actual-title", dedup="shadow-1")) + + execution = OrchestrationExecution.get() + assert execution.trace_json["mode"] == "shadow" + assert execution.trace_json["actual_result"]["route_id"] == route.id + assert execution.trace_json["actual_result"]["title"] == "actual-title" + + metrics = shadow_metrics(orchestration.id) + assert metrics["metrics"]["executions"] == 1 + assert metrics["metrics"]["title_changes"] == 1 + assert metrics["metrics"]["routing_changes"] == 0 + + explain = ( + AlertExplainStep.select() + .where( + (AlertExplainStep.trace == result.trace.row.id) + & (AlertExplainStep.stage == "orchestration") + ) + .order_by(AlertExplainStep.position.asc()) + .first() + ) + executions = explain.data["orchestration"]["executions"] + assert executions[0]["trace"]["result"]["rules"][0]["matched"] is True + + +def test_list_executions_redacts_trace_secrets(db): + group = create_group() + orchestration, published, _ = _published_orchestration(group) + execution = OrchestrationExecution.create( + group=group, + orchestration=orchestration, + version=published, + source="webhook", + trace_json={"initial_context": {"raw": {"api_token": "secret"}}}, + ) + + rows = list_executions( + orchestration.id, + include_trace=True, + ) + + assert rows[0]["id"] == execution.id + assert rows[0]["trace"]["initial_context"]["raw"]["api_token"] == "***REDACTED***" + + +def test_simulation_never_enqueues_webhook_actions(db): + group = create_group() + team = create_team(group) + route = create_route(team) + user = create_user(group=group) + action = create_webhook_action( + group_id=group.id, + name="simulated diagnostics", + url="https://example.test/diagnostics", + ) + orchestration = create_orchestration( + group_id=group.id, + name="simulated-webhook", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + _rule( + "simulate only", + extra_actions=[ + {"type": "enqueue_webhook", "action_id": action.id}, + ], + ), + ) + + result = simulate_event( + orchestration.id, + _event(route, dedup="sim-webhook"), + version_id=draft.id, + ) + + assert result["executed"] is True + requests = result["final_context"]["result"]["webhooks"] + assert requests == [{"action_id": action.id}] + assert AutomationExecution.select().count() == 0 + assert OrchestrationExecution.select().count() == 0 + + +def test_simulation_rejects_oversized_payload(monkeypatch): + monkeypatch.setattr(Config, "ORCHESTRATION_SIMULATION_MAX_PAYLOAD_BYTES", 1024) + + with pytest.raises(OrchestrationSimulationError, match="simulation limit"): + normalize_simulation_payload( + source="webhook", + payload={"title": "x" * 2048}, + ) + + +def test_shadow_metrics_do_not_compare_legacy_rows_without_actual_result(db): + group = create_group() + orchestration, published, _ = _published_orchestration(group, mode="shadow") + OrchestrationExecution.create( + group=group, + orchestration=orchestration, + version=published, + source="webhook", + trace_json={ + "mode": "shadow", + "result": { + "context": { + "event": {"title": "candidate"}, + "result": {}, + } + }, + }, + ) + + result = shadow_metrics(orchestration.id) + + assert result["metrics"]["executions"] == 1 + assert result["metrics"]["comparable_executions"] == 0 + assert result["metrics"]["not_comparable"] == 1 + assert result["metrics"]["title_changes"] == 0 + + +def test_active_comparison_uses_one_time_snapshot(db): + group = create_group() + team = create_team(group) + route = create_route(team) + orchestration, published, _ = _published_orchestration(group, title="same") + + result = simulate_event( + orchestration.id, + _event(route, dedup="same-time"), + version_id=published.id, + compare_with_active=True, + ) + + assert result["evaluated_at"] == result["active"]["evaluated_at"] + assert result["active_draft_diff"]["changed"] is False + + +def test_replay_reports_high_drop_rate_without_applying_it(db, monkeypatch): + group = create_group() + team = create_team(group) + route = create_route(team) + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="drop-replay", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [ + { + "name": "drop candidate", + "condition_tree": {}, + "actions": [{"type": "drop", "reason": "candidate"}], + "processing_mode": "continue", + } + ], + ) + alert = create_alert(route) + alert_count = Alert.select().count() + monkeypatch.setattr(Config, "ORCHESTRATION_REPLAY_DROP_WARNING_PERCENT", 50) + + result = replay_events( + orchestration.id, + alert_ids=[alert.id], + version_id=draft.id, + ) + + assert result["summary"]["dispositions"] == {"drop": 1} + assert result["summary"]["drop_percentage"] == 100.0 + assert result["warnings"][0]["code"] == "high_drop_rate" + assert Alert.select().count() == alert_count diff --git a/tests/orchestration/test_orchestration_simulator_api.py b/tests/orchestration/test_orchestration_simulator_api.py new file mode 100644 index 0000000..8915ab6 --- /dev/null +++ b/tests/orchestration/test_orchestration_simulator_api.py @@ -0,0 +1,239 @@ +import pytest + +from app.login import create_access_token +from app.modules.db.models import ( + AutomationExecution, + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + OrchestrationWebhookAction, + PendingOrchestratedEvent, +) +from app.modules.db.orchestrations_repo import ( + create_orchestration, + get_or_create_draft, + publish_draft, + replace_draft_rules, +) +from tests.factories import create_alert, create_group, create_route, create_team, create_user + + +@pytest.fixture(autouse=True) +def orchestration_api_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + PendingOrchestratedEvent, + OrchestrationWebhookAction, + AutomationExecution, + ], + safe=True, + ) + AutomationExecution.delete().execute() + OrchestrationWebhookAction.delete().execute() + PendingOrchestratedEvent.delete().execute() + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _fixture(group): + user = create_user(group=group) + orchestration = create_orchestration( + group_id=group.id, + name="api simulator", + created_by_id=user.id, + ) + draft = get_or_create_draft(orchestration.id, actor_id=user.id) + replace_draft_rules( + draft.id, + [ + { + "name": "critical", + "condition_tree": {}, + "actions": [{"type": "set_severity", "value": "critical"}], + "processing_mode": "continue", + } + ], + ) + published = publish_draft(orchestration.id, actor_id=user.id) + return orchestration, published + + +def _headers(user): + token, _ = create_access_token(user) + return {"Authorization": f"Bearer {token}"} + + +def test_simulate_api_accepts_normalized_event(client, db): + group = create_group() + user = create_user(group=group, group_role="editor") + orchestration, published = _fixture(group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/simulate", + headers=_headers(user), + json={ + "version_id": published.id, + "normalized_event": { + "source": "webhook", + "dedup_key": "api-1", + "title": "API event", + "labels": {}, + "payload": {}, + }, + }, + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["selected_normalizer"] == "normalized" + assert body["final_context"]["event"]["severity"] == "critical" + assert OrchestrationExecution.select().count() == 0 + + +def test_simulate_api_denies_group_viewer(client, db): + group = create_group() + viewer = create_user(group=group, group_role="viewer") + orchestration, _ = _fixture(group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/simulate", + headers=_headers(viewer), + json={ + "normalized_event": { + "source": "webhook", + "dedup_key": "api-2", + "title": "API event", + "labels": {}, + } + }, + ) + + assert response.status_code == 403 + assert response.get_json()["error"] == "forbidden" + + +def test_replay_api_uses_stored_alert_without_creating_new_alert(client, db): + group = create_group() + editor = create_user(group=group, group_role="editor") + team = create_team(group) + route = create_route(team) + alert = create_alert(route) + orchestration, published = _fixture(group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/replay", + headers=_headers(editor), + json={ + "alert_ids": [alert.id], + "version_id": published.id, + }, + ) + + assert response.status_code == 200 + body = response.get_json() + assert body["count"] == 1 + assert body["successful"] == 1 + assert body["production_state_modified"] is False + + +def test_execution_and_shadow_metrics_api_are_visible_to_viewer(client, db): + group = create_group() + viewer = create_user(group=group, group_role="viewer") + orchestration, published = _fixture(group) + OrchestrationExecution.create( + group=group, + orchestration=orchestration, + version=published, + source="webhook", + trace_json={"mode": "shadow", "result": {"context": {}}}, + ) + + executions = client.get( + f"/api/event-orchestrations/{orchestration.id}/executions?include_trace=1", + headers=_headers(viewer), + ) + metrics = client.get( + f"/api/event-orchestrations/{orchestration.id}/shadow-metrics", + headers=_headers(viewer), + ) + + assert executions.status_code == 200 + assert len(executions.get_json()) == 1 + assert metrics.status_code == 200 + assert metrics.get_json()["metrics"]["executions"] == 1 + + +def test_replay_api_rejects_alert_from_another_group(client, db): + owner_group = create_group(slug="sim-owner") + foreign_group = create_group(slug="sim-foreign") + editor = create_user(group=owner_group, group_role="editor") + foreign_team = create_team(foreign_group) + foreign_route = create_route(foreign_team) + foreign_alert = create_alert(foreign_route) + orchestration, published = _fixture(owner_group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/replay", + headers=_headers(editor), + json={ + "alert_ids": [foreign_alert.id], + "version_id": published.id, + }, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "orchestration_simulation_invalid" + + +def test_simulate_api_rejects_ambiguous_input(client, db): + group = create_group() + editor = create_user(group=group, group_role="editor") + orchestration, _ = _fixture(group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/simulate", + headers=_headers(editor), + json={ + "source": "webhook", + "payload": {"title": "raw"}, + "normalized_event": { + "source": "webhook", + "dedup_key": "ambiguous", + "title": "normalized", + }, + }, + ) + + assert response.status_code == 400 + assert response.get_json()["error"] == "validation_error" + + +def test_simulate_api_rejects_unauthenticated_request(client, db): + group = create_group() + orchestration, _ = _fixture(group) + + response = client.post( + f"/api/event-orchestrations/{orchestration.id}/simulate", + json={ + "normalized_event": { + "source": "webhook", + "dedup_key": "anonymous", + "title": "Anonymous event", + "labels": {}, + } + }, + ) + + assert response.status_code == 403 + assert response.get_json()["error"] == "forbidden" From 5a94c0d84ed1b25e5306f08014a2ad349a09261d Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Thu, 23 Jul 2026 11:45:24 +0300 Subject: [PATCH 08/34] Fix orchestration simulator API test to require JWT or API token authentication (401 response) instead of forbidden (403). --- tests/orchestration/test_orchestration_simulator_api.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/orchestration/test_orchestration_simulator_api.py b/tests/orchestration/test_orchestration_simulator_api.py index 8915ab6..878026f 100644 --- a/tests/orchestration/test_orchestration_simulator_api.py +++ b/tests/orchestration/test_orchestration_simulator_api.py @@ -235,5 +235,5 @@ def test_simulate_api_rejects_unauthenticated_request(client, db): }, ) - assert response.status_code == 403 - assert response.get_json()["error"] == "forbidden" + assert response.status_code == 401 + assert response.get_json()["error"] == "JWT or API token authentication is required" From 17ce835c9b7b844a59bf471fc038c4f977a4d90c Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Fri, 24 Jul 2026 09:02:33 +0300 Subject: [PATCH 09/34] Fix escalation flow blocking and UI matcher format handling #31 --- app/services/alerts/escalation.py | 89 +++++++++++++------ app/services/routing/matcher/matchers.py | 32 +++++++ tests/alerts/test_alerts_service_extended.py | 85 ++++++++++++++++++ .../test_notification_policy_resolver.py | 49 ++++++++++ tests/test_escalation_policies.py | 64 +++++++++++++ tests/test_matchers.py | 23 +++++ 6 files changed, 313 insertions(+), 29 deletions(-) diff --git a/app/services/alerts/escalation.py b/app/services/alerts/escalation.py index ed280ff..8ef98d8 100644 --- a/app/services/alerts/escalation.py +++ b/app/services/alerts/escalation.py @@ -10,6 +10,64 @@ logger = logging.getLogger("oncall.alerts") +def _deliver_escalation_notification(group): + """Attempt escalation delivery without affecting the state transition.""" + has_target = False + resolution_error = None + + try: + has_target = has_matching_notification_channel( + group, + event_type="escalation", + ) + except Exception as exc: + resolution_error = exc + logger.exception( + "escalation notification target resolution failed", + extra={ + "extra": { + "alert_group_id": group.id, + "route_id": group.route.id if group.route else None, + } + }, + ) + + try: + sent_count = notify_alert(group, event_type="escalation") + except Exception as exc: + sent_count = 0 + resolution_error = resolution_error or exc + logger.exception( + "escalation notification delivery failed", + extra={ + "extra": { + "alert_group_id": group.id, + "route_id": group.route.id if group.route else None, + } + }, + ) + + if sent_count: + return sent_count + + if has_target or resolution_error is not None: + event_type = "escalation_notification_failed" + message = "Escalation completed, but notification delivery did not succeed." + else: + event_type = "escalation_notification_skipped" + message = ( + "Escalation completed, but no matching notification target was found." + ) + + alerts_repo.create_alert_event( + group_id=group.id, + event_type=event_type, + message=message, + ) + + return 0 + + def apply_initial_escalation_policy_assignment(policy, fallback_rotation): """Return initial policy rule, rotation, assignee and next escalation time.""" policy_rule = None @@ -51,19 +109,6 @@ def maybe_escalate_alert(group): if not group.team or not group.team.escalation_enabled: return False - if not has_matching_notification_channel(group, event_type="escalation"): - logger.debug( - "escalation skipped because no channel matches alert group severity", - extra={ - "extra": { - "alert_group_id": group.id, - "severity": group.severity, - "route_id": group.route.id if group.route else None, - } - }, - ) - return False - if group.reminder_count < group.team.escalation_after_reminders: return False @@ -87,7 +132,7 @@ def maybe_escalate_alert(group): message=f"Escalated to {next_user.username}", ) - notify_alert(group, event_type="escalation") + _deliver_escalation_notification(group) return True @@ -106,20 +151,6 @@ def maybe_escalate_alert_by_policy(group): if group.next_escalation_at > now: return False - if not has_matching_notification_channel(group, event_type="escalation"): - logger.debug( - "policy escalation skipped because no channel matches alert group severity", - extra={ - "extra": { - "alert_group_id": group.id, - "severity": group.severity, - "route_id": group.route.id if group.route else None, - "escalation_policy_id": group.escalation_policy_id, - } - }, - ) - return False - next_rule, repeat_count = escalation_policy_service.get_next_rule_for_alert(group) if not next_rule: @@ -163,6 +194,6 @@ def maybe_escalate_alert_by_policy(group): message=f"Escalated by policy rule #{next_rule.position} to {target}", ) - notify_alert(group, event_type="escalation") + _deliver_escalation_notification(group) return True diff --git a/app/services/routing/matcher/matchers.py b/app/services/routing/matcher/matchers.py index a47e591..c5c59c3 100644 --- a/app/services/routing/matcher/matchers.py +++ b/app/services/routing/matcher/matchers.py @@ -84,6 +84,38 @@ def match_value(actual_value, expected_value): return actual_value in expected_value if isinstance(expected_value, dict): + operator = expected_value.get("op") + + if operator is not None: + operator = { + "eq": "equals", + "ne": "not_equals", + "neq": "not_equals", + }.get(str(operator).strip().lower(), str(operator).strip().lower()) + operator_value = expected_value.get("value") + actual_text = str(actual_value) + expected_text = str(operator_value) + + if operator == "regex": + return re.search( + str(operator_value or ""), + str(actual_value or ""), + ) is not None + + if operator == "equals": + return actual_text == expected_text + + if operator == "not_equals": + return actual_text != expected_text + + if operator == "contains": + return str(operator_value or "") in str(actual_value or "") + + if operator == "not_contains": + return str(operator_value or "") not in str(actual_value or "") + + return False + if "regex" in expected_value: return re.search( expected_value["regex"], diff --git a/tests/alerts/test_alerts_service_extended.py b/tests/alerts/test_alerts_service_extended.py index c976a91..411e7e4 100644 --- a/tests/alerts/test_alerts_service_extended.py +++ b/tests/alerts/test_alerts_service_extended.py @@ -701,3 +701,88 @@ def test_policy_alert_assigns_rotation_when_first_rule_was_deleted_and_second_is assert alert_group.rotation_id == rotation.id assert alert_group.assignee_id == alice.id assert alert_group.next_escalation_at is not None + + +def test_maybe_escalate_alert_updates_state_without_notification_target( + monkeypatch, + db, +): + group = create_group(slug="infra-no-escalation-target") + team = create_team(group, slug="sre-no-escalation-target") + team.escalation_after_reminders = 0 + team.save() + first = create_user("alice-no-target", group) + second = create_user("bob-no-target", group) + add_user_to_team(team, first) + add_user_to_team(team, second) + rotation = create_rotation(team, users=[first, second]) + route = create_route(team, rotation=rotation) + alert_group = create_alert_group_for_route(route) + alert_group.assignee = first + alert_group.reminder_count = 1 + alert_group.save() + + monkeypatch.setattr( + alert_escalation, + "has_matching_notification_channel", + lambda *args, **kwargs: False, + ) + monkeypatch.setattr( + alert_escalation, + "notify_alert", + lambda *args, **kwargs: 0, + ) + + assert maybe_escalate_alert(alert_group) is True + + stored = AlertGroup.get_by_id(alert_group.id) + + assert stored.assignee == second + assert stored.escalation_level == 1 + assert stored.reminder_count == 0 + assert AlertEvent.select().where( + (AlertEvent.group == stored.id) + & (AlertEvent.event_type == "escalation_notification_skipped") + ).exists() + + +def test_maybe_escalate_alert_records_notification_failure_after_transition( + monkeypatch, + db, +): + group = create_group(slug="infra-escalation-failure") + team = create_team(group, slug="sre-escalation-failure") + team.escalation_after_reminders = 0 + team.save() + first = create_user("alice-delivery-failure", group) + second = create_user("bob-delivery-failure", group) + add_user_to_team(team, first) + add_user_to_team(team, second) + rotation = create_rotation(team, users=[first, second]) + route = create_route(team, rotation=rotation) + alert_group = create_alert_group_for_route(route) + alert_group.assignee = first + alert_group.reminder_count = 1 + alert_group.save() + + monkeypatch.setattr( + alert_escalation, + "has_matching_notification_channel", + lambda *args, **kwargs: True, + ) + + def fail_delivery(*args, **kwargs): + raise RuntimeError("provider unavailable") + + monkeypatch.setattr(alert_escalation, "notify_alert", fail_delivery) + + assert maybe_escalate_alert(alert_group) is True + + stored = AlertGroup.get_by_id(alert_group.id) + + assert stored.assignee == second + assert stored.escalation_level == 1 + assert AlertEvent.select().where( + (AlertEvent.group == stored.id) + & (AlertEvent.event_type == "escalation_notification_failed") + ).exists() diff --git a/tests/notifications/notification_policies/test_notification_policy_resolver.py b/tests/notifications/notification_policies/test_notification_policy_resolver.py index 5c87a65..b814e47 100644 --- a/tests/notifications/notification_policies/test_notification_policy_resolver.py +++ b/tests/notifications/notification_policies/test_notification_policy_resolver.py @@ -291,3 +291,52 @@ def test_disabled_policy_channel_is_ignored(): resolution = resolve_notification_channels(alert_group) assert resolution.channels == [] + + +def test_service_policy_matches_ui_operator_value_regex(): + group = create_group() + team = create_team(group) + service = create_service(team) + database_channel = create_channel(group, team) + default_channel = create_channel(group, team) + + policy = create_notification_policy(team) + database_rule = create_notification_policy_rule( + policy, + position=1, + matchers={ + "labels": { + "Application": { + "op": "regex", + "value": "^(Listener|DB)$", + } + } + }, + channels=[database_channel], + ) + create_notification_policy_rule( + policy, + position=2, + matchers={}, + channels=[default_channel], + ) + + service.notification_policy = policy + service.save() + + route = create_route( + team, + service=service, + notification_channel_mode="service_policy", + ) + + alert_group = _create_group(team, route, service) + labels = dict(alert_group.common_labels or {}) + labels["Application"] = "DB" + alert_group.common_labels = labels + alert_group.save() + + resolution = resolve_notification_channels(alert_group) + + assert _channel_ids(resolution) == [database_channel.id] + assert resolution.matched_rule_ids == [database_rule.id] diff --git a/tests/test_escalation_policies.py b/tests/test_escalation_policies.py index 5dc20bb..1cb7e92 100644 --- a/tests/test_escalation_policies.py +++ b/tests/test_escalation_policies.py @@ -459,3 +459,67 @@ def test_policy_exhausted_alert_does_not_send_more_reminders(monkeypatch, db): assert send_unacked_reminders() == 0 assert calls == [] + + +def test_policy_escalation_moves_to_next_rule_without_notification_target( + monkeypatch, + db, +): + group = create_group(slug="infra-policy-no-target") + team = create_team(group, slug="sre-policy-no-target") + first_user = create_user("alice-policy-no-target", group) + second_user = create_user("bob-policy-no-target", group) + add_user_to_team(team, first_user) + add_user_to_team(team, second_user) + first_rotation = create_rotation(team, name="Primary no target", users=[first_user]) + second_rotation = create_rotation(team, name="Backup no target", users=[second_user]) + policy = create_escalation_policy(team) + first_rule = create_escalation_policy_rule( + policy, + position=1, + delay_seconds=60, + target_type="rotation", + rotation=first_rotation, + ) + second_rule = create_escalation_policy_rule( + policy, + position=2, + delay_seconds=120, + target_type="rotation", + rotation=second_rotation, + ) + create_route(team, escalation_policy=policy) + + monkeypatch.setattr(notification_queue, "notify_alert", lambda *args, **kwargs: 1) + + result = upsert_alert(normalized_alert()) + alert_group = result.group + + assert alert_group.escalation_rule.id == first_rule.id + + alert_group.next_escalation_at = utc_now() - timedelta(seconds=1) + alert_group.save() + + monkeypatch.setattr( + alert_escalation, + "has_matching_notification_channel", + lambda *args, **kwargs: False, + ) + monkeypatch.setattr( + alert_escalation, + "notify_alert", + lambda *args, **kwargs: 0, + ) + + assert maybe_escalate_alert(alert_group) is True + + stored = AlertGroup.get_by_id(alert_group.id) + + assert stored.escalation_rule.id == second_rule.id + assert stored.rotation.id == second_rotation.id + assert stored.assignee.id == second_user.id + assert stored.escalation_level == 1 + assert AlertEvent.select().where( + (AlertEvent.group == stored.id) + & (AlertEvent.event_type == "escalation_notification_skipped") + ).exists() diff --git a/tests/test_matchers.py b/tests/test_matchers.py index 577367e..54ad801 100644 --- a/tests/test_matchers.py +++ b/tests/test_matchers.py @@ -49,3 +49,26 @@ def test_match_alert_supports_top_level_labels_title_regex_and_fields(): assert not match_alert(ALERT, {"severity": "warning"}) assert not match_alert(ALERT, {"source": "zabbix"}) assert not match_alert(ALERT, {"title_regex": "^CPU"}) + + +def test_match_value_supports_canonical_operator_value_format(): + assert match_value("DB", {"op": "regex", "value": "^(Listener|DB)$"}) + assert match_value("critical", {"op": "equals", "value": "critical"}) + assert match_value("critical", {"op": "eq", "value": "critical"}) + assert match_value("critical", {"op": "not_equals", "value": "warning"}) + assert match_value("critical", {"op": "neq", "value": "warning"}) + assert match_value("database-primary", {"op": "contains", "value": "database"}) + assert match_value("database-primary", {"op": "not_contains", "value": "cache"}) + + assert not match_value("API", {"op": "regex", "value": "^(Listener|DB)$"}) + assert not match_value("critical", {"op": "equals", "value": "warning"}) + assert not match_value("critical", {"op": "not_equals", "value": "critical"}) + assert not match_value("database-primary", {"op": "contains", "value": "cache"}) + assert not match_value( + "database-primary", + {"op": "not_contains", "value": "database"}, + ) + + +def test_match_value_rejects_unknown_operator(): + assert not match_value("critical", {"op": "unknown", "value": "critical"}) From cee94a9e0f5baf759cd226ec0a5141238e291754 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Fri, 24 Jul 2026 09:26:01 +0300 Subject: [PATCH 10/34] Update escalation policy test to validate alert group creation and association --- tests/test_escalation_policies.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_escalation_policies.py b/tests/test_escalation_policies.py index 1cb7e92..f113442 100644 --- a/tests/test_escalation_policies.py +++ b/tests/test_escalation_policies.py @@ -492,7 +492,13 @@ def test_policy_escalation_moves_to_next_rule_without_notification_target( monkeypatch.setattr(notification_queue, "notify_alert", lambda *args, **kwargs: 1) - result = upsert_alert(normalized_alert()) + result = upsert_alert( + normalized_alert(team_slug=team.slug) + ) + + assert result.group is not None + assert result.created_group is True + alert_group = result.group assert alert_group.escalation_rule.id == first_rule.id From caaa2ad85b38ca82a96c74f6b29564cece35d5d3 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Sat, 25 Jul 2026 09:37:10 +0300 Subject: [PATCH 11/34] Implements UI rule builder and management Part of #16 Closes #24 --- app/__init__.py | 9 +- app/api/schemas/orchestrations.py | 78 +- app/modules/db/orchestrations_repo.py | 113 ++ app/static/css/orchestrations.css | 56 + app/static/i18n/de/orchestrations.json | 135 +++ app/static/i18n/en/orchestrations.json | 135 +++ app/static/i18n/ru/orchestrations.json | 135 +++ app/static/js/core/state.js | 1 + app/static/js/pages/orchestrations.js | 962 ++++++++++++++++++ app/templates/index.html | 8 + app/templates/pages/orchestrations.html | 139 +++ app/views/orchestrations_view.py | 743 +++++++++++++- app/views/pages_view.py | 2 + .../test_orchestration_control_plane_api.py | 289 ++++++ .../test_orchestration_ui_assets.py | 76 ++ 15 files changed, 2844 insertions(+), 37 deletions(-) create mode 100644 app/static/css/orchestrations.css create mode 100644 app/static/i18n/de/orchestrations.json create mode 100644 app/static/i18n/en/orchestrations.json create mode 100644 app/static/i18n/ru/orchestrations.json create mode 100644 app/static/js/pages/orchestrations.js create mode 100644 app/templates/pages/orchestrations.html create mode 100644 tests/orchestration/test_orchestration_control_plane_api.py create mode 100644 tests/orchestration/test_orchestration_ui_assets.py diff --git a/app/__init__.py b/app/__init__.py index 8465490..66e621f 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -41,7 +41,10 @@ from app.views.matchers_view import matchers_bp from app.views.business_services.routes import business_services_bp from app.views.heartbeats_view import heartbeats_bp -from app.views.orchestrations_view import orchestrations_bp +from app.views.orchestrations_view import ( + orchestrations_bp, + orchestration_webhook_actions_bp, +) def create_app(log_role=None): @@ -132,3 +135,7 @@ def register_blueprints(flask_app): flask_app.register_blueprint(business_services_bp) flask_app.register_blueprint(heartbeats_bp, url_prefix="/api/heartbeats") flask_app.register_blueprint(orchestrations_bp, url_prefix="/api/event-orchestrations") + flask_app.register_blueprint( + orchestration_webhook_actions_bp, + url_prefix="/api/orchestration-webhook-actions", + ) diff --git a/app/api/schemas/orchestrations.py b/app/api/schemas/orchestrations.py index 6bbda7f..760b3c5 100644 --- a/app/api/schemas/orchestrations.py +++ b/app/api/schemas/orchestrations.py @@ -1,6 +1,6 @@ -"""Request schemas for Event Orchestration simulation and replay.""" +"""Request schemas for Event Orchestration control-plane APIs.""" -from typing import Any, Dict +from typing import Any, Dict, Literal from pydantic import Field, field_validator, model_validator @@ -10,6 +10,53 @@ ) +class OrchestrationCreateSchema(ApiModel): + group_id: int = Field(ge=1) + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + scope: Literal["global", "service"] = "global" + service_id: int | None = Field(default=None, ge=1) + compatibility_mode: Literal["legacy", "hybrid", "orchestration"] = "legacy" + + @model_validator(mode="after") + def validate_scope(self): + if self.scope == "global" and self.service_id is not None: + raise ValueError("global orchestration cannot reference a service") + if self.scope == "service" and self.service_id is None: + raise ValueError("service-scoped orchestration requires service_id") + return self + + +class OrchestrationUpdateSchema(ApiModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + + +class OrchestrationDraftSchema(ApiModel): + rules: list[Dict[str, Any]] = Field(default_factory=list, max_length=512) + comment: str | None = Field(default=None, max_length=8192) + + +class OrchestrationPublishSchema(ApiModel): + comment: str | None = Field(default=None, max_length=8192) + confirm_catch_all_drop: bool = False + + +class OrchestrationRollbackSchema(ApiModel): + version_id: int = Field(ge=1) + comment: str | None = Field(default=None, max_length=8192) + confirm_catch_all_drop: bool = False + + +class OrchestrationRuntimeSchema(ApiModel): + mode: Literal["active", "shadow", "disabled"] + compatibility_mode: Literal["legacy", "hybrid", "orchestration"] = "legacy" + + @property + def enabled(self) -> bool: + return self.mode != "disabled" + + class OrchestrationSimulationSchema(ApiModel): source: str | None = Field(default=None, max_length=128) payload: Any | None = None @@ -67,3 +114,30 @@ def require_inputs(self): "at least one alert_id or execution_id is required" ) return self + + +class OrchestrationWebhookActionCreateSchema(ApiModel): + group_id: int = Field(ge=1) + name: str = Field(min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + url: str = Field(min_length=1, max_length=4096) + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "POST" + headers: Dict[str, Any] = Field(default_factory=dict) + body_template: str | None = Field(default=None, max_length=65536) + timeout_seconds: int = Field(default=10, ge=1, le=60) + retry_count: int = Field(default=2, ge=0, le=10) + private_network_policy: Literal["deny", "allowlist"] = "deny" + enabled: bool = True + + +class OrchestrationWebhookActionUpdateSchema(ApiModel): + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=8192) + url: str | None = Field(default=None, min_length=1, max_length=4096) + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] | None = None + headers: Dict[str, Any] | None = None + body_template: str | None = Field(default=None, max_length=65536) + timeout_seconds: int | None = Field(default=None, ge=1, le=60) + retry_count: int | None = Field(default=None, ge=0, le=10) + private_network_policy: Literal["deny", "allowlist"] | None = None + enabled: bool | None = None diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index 71c0c9a..a84b8dd 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -219,6 +219,93 @@ def create_orchestration( ) from exc + + +def get_orchestration(orchestration_id: int) -> EventOrchestration: + """Return a non-deleted orchestration by id.""" + orchestration = _get_orchestration(orchestration_id) + if bool(getattr(orchestration, "deleted", False)) or getattr( + orchestration, "deleted_at", None + ) is not None: + raise OrchestrationNotFound("Event orchestration not found") + return orchestration + + +def list_orchestrations( + *, + group_ids: Optional[Sequence[int]] = None, + group_id: Optional[int] = None, +) -> List[EventOrchestration]: + """List non-deleted orchestrations visible in the requested groups.""" + query = EventOrchestration.select().where( + (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + if group_id is not None: + query = query.where(EventOrchestration.group == int(group_id)) + elif group_ids is not None: + normalized = sorted({int(value) for value in group_ids}) + if not normalized: + return [] + query = query.where(EventOrchestration.group.in_(normalized)) + return list( + query.order_by( + EventOrchestration.group.asc(), + EventOrchestration.name.asc(), + EventOrchestration.id.asc(), + ) + ) + + +def update_orchestration( + orchestration_id: int, + *, + name: Optional[str] = None, + description: Optional[str] = None, + description_provided: bool = False, +) -> EventOrchestration: + """Update editable orchestration metadata without changing runtime state.""" + with database_proxy.atomic(): + orchestration = _locked_orchestration(orchestration_id) + if name is not None: + normalized_name = str(name or "").strip() + if not normalized_name: + raise OrchestrationValidationError(["name is required"]) + orchestration.name = normalized_name + if description_provided: + orchestration.description = description + try: + orchestration.save() + except IntegrityError as exc: + raise OrchestrationConflict( + "An orchestration with this name already exists in the group" + ) from exc + return get_orchestration(orchestration_id) + + +def list_versions(orchestration_id: int) -> List[EventOrchestrationVersion]: + orchestration = get_orchestration(orchestration_id) + return list( + EventOrchestrationVersion.select() + .where(EventOrchestrationVersion.orchestration == orchestration.id) + .order_by( + EventOrchestrationVersion.version_number.desc(), + EventOrchestrationVersion.id.desc(), + ) + ) + + +def get_version( + orchestration_id: int, + version_id: int, +) -> EventOrchestrationVersion: + orchestration = get_orchestration(orchestration_id) + version = _get_version(version_id) + if version.orchestration_id != orchestration.id: + raise OrchestrationNotFound("Orchestration version not found") + return version + + def get_draft(orchestration_id: int) -> Optional[EventOrchestrationVersion]: return ( EventOrchestrationVersion.select() @@ -320,6 +407,32 @@ def replace_draft_rules( return _get_version(version_id) +def save_draft_definition( + orchestration_id: int, + rules: Sequence[Dict[str, Any]], + *, + actor_id: Optional[int] = None, + comment: Optional[str] = None, +) -> EventOrchestrationVersion: + """Create/update one draft definition and its metadata atomically.""" + with database_proxy.atomic(): + draft = get_or_create_draft( + orchestration_id, + actor_id=actor_id, + comment=comment, + ) + draft = replace_draft_rules(draft.id, rules) + if comment is not None: + EventOrchestrationVersion.update( + comment=comment, + updated_at=_utcnow(), + ).where( + EventOrchestrationVersion.id == draft.id + ).execute() + draft = _get_version(draft.id) + return draft + + def _clone_version_rules( source_version_id: int, target_version: EventOrchestrationVersion, diff --git a/app/static/css/orchestrations.css b/app/static/css/orchestrations.css new file mode 100644 index 0000000..51a81bd --- /dev/null +++ b/app/static/css/orchestrations.css @@ -0,0 +1,56 @@ +.orchestration-workspace { overflow: hidden; } +.orchestration-workspace-header { align-items: flex-start; } +.orchestration-workspace-header h2 { margin: 10px 0 4px; } +.orchestration-tabs { padding: 0 20px; border-bottom: 1px solid var(--border-color, #dfe4ea); overflow-x: auto; } +.orchestration-tab-panel { padding: 20px; } +.orchestration-rule-list { display: grid; gap: 12px; } +.orchestration-rule-card { border: 1px solid var(--border-color, #dfe4ea); border-radius: 12px; padding: 14px; background: var(--card-background, #fff); } +.orchestration-rule-card-header, .orchestration-rule-controls, .orchestration-condition-group-header, .orchestration-action-row { display: flex; gap: 8px; align-items: center; } +.orchestration-rule-card-header { justify-content: space-between; } +.orchestration-rule-card-body { display: grid; gap: 8px; margin: 12px 0; } +.orchestration-rule-card-body > div { display: grid; grid-template-columns: 72px 1fr; gap: 8px; } +.orchestration-keyword { font-weight: 700; color: #344054; letter-spacing: .04em; } +.orchestration-rule-controls { justify-content: flex-end; flex-wrap: wrap; } +.orchestration-code, .orchestration-output { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.orchestration-json-panel { display: grid; gap: 12px; } +.orchestration-json-actions { justify-content: flex-end; } +.orchestration-output { min-height: 180px; max-height: 620px; overflow: auto; padding: 14px; border: 1px solid var(--border-color, #dfe4ea); border-radius: 10px; background: #0f172a; color: #e2e8f0; white-space: pre-wrap; word-break: break-word; } +.orchestration-simulator-grid > .card { min-width: 0; } +.orchestration-condition-editor, .orchestration-action-editor { display: grid; gap: 10px; } +.orchestration-condition-group { border-left: 3px solid #84adff; padding: 10px 0 4px 12px; margin: 4px 0; } +.orchestration-condition-group-header { flex-wrap: wrap; margin-bottom: 8px; } +.orchestration-condition-group-header select { width: 110px; } +.orchestration-condition-children { display: grid; gap: 8px; } +.orchestration-condition-leaf { display: grid; grid-template-columns: minmax(160px, 1.4fr) minmax(140px, .9fr) minmax(180px, 1.4fr) auto; gap: 8px; align-items: center; } +.orchestration-action-row { display: grid; grid-template-columns: minmax(180px, .8fr) minmax(300px, 2fr) auto; border: 1px solid var(--border-color, #dfe4ea); border-radius: 10px; padding: 10px; } +.orchestration-action-params { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } +.orchestration-message { padding: 12px; border-radius: 8px; white-space: pre-wrap; margin-bottom: 12px; } +.orchestration-message.validation-success { background: #ecfdf3; color: #027a48; border: 1px solid #abefc6; } +.orchestration-message.validation-error { background: #fef3f2; color: #b42318; border: 1px solid #fecdca; } +.orchestration-metrics { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; } +.orchestration-metric { min-width: 140px; display: flex; justify-content: space-between; gap: 18px; padding: 9px 12px; border: 1px solid var(--border-color, #dfe4ea); border-radius: 8px; } +.orchestration-diff-grid { margin-top: 18px; } +.orchestration-rule-editor-grid { align-items: start; } +body.md-theme #orchestration-webhook-modal .app-modal-dialog-wide, +#orchestration-webhook-modal .app-modal-dialog-wide { width: min(1120px, calc(100vw - 48px)); } +.orchestration-webhook-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; align-items: start; } +.orchestration-webhook-section { min-width: 0; margin: 0; overflow: hidden; } +.orchestration-webhook-section .form-body { min-width: 0; } +.orchestration-webhook-section .input, +.orchestration-webhook-section .json-editor { width: 100%; box-sizing: border-box; } +.orchestration-webhook-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.orchestration-webhook-options > div { display: grid; gap: 9px; min-width: 0; } +.btn-danger { color: #b42318; border-color: #fda29b; } +.btn-success { color: #fff; background: #039855; border-color: #039855; } +.status-badge.status-warning { background: #fffaeb; color: #b54708; } +.status-badge.status-muted { background: #f2f4f7; color: #475467; } +.status-badge.status-active { background: #ecfdf3; color: #027a48; } +@media (max-width: 900px) { + .orchestration-condition-leaf, .orchestration-action-row { grid-template-columns: 1fr; } + .orchestration-rule-card-body > div { grid-template-columns: 1fr; } + .orchestration-webhook-grid, .orchestration-webhook-options { grid-template-columns: 1fr; } +} + +.orchestration-rule-card[draggable="true"] { cursor: grab; } +.orchestration-rule-card[draggable="true"]:active { cursor: grabbing; } +.orchestration-rule-card.is-drag-over { outline: 2px solid currentColor; outline-offset: 3px; } diff --git a/app/static/i18n/de/orchestrations.json b/app/static/i18n/de/orchestrations.json new file mode 100644 index 0000000..42b142d --- /dev/null +++ b/app/static/i18n/de/orchestrations.json @@ -0,0 +1,135 @@ +{ + "nav.event_orchestration": "Ereignis-Orchestrierung", + "pages.orchestrations.title": "Ereignis-Orchestrierung", + "pages.orchestrations.subtitle": "Eingehende Ereignisse routen, anreichern, unterdrücken und automatisieren", + "orchestrations.summary.total": "Orchestrierungen", + "orchestrations.summary.total_hint": "Definitions in the selected group", + "orchestrations.summary.active": "Aktiv", + "orchestrations.summary.active_hint": "Applied to production events", + "orchestrations.summary.shadow": "Schattenmodus", + "orchestrations.summary.shadow_hint": "Evaluated without changing behavior", + "orchestrations.summary.drafts": "Entwürfe", + "orchestrations.summary.drafts_hint": "Unpublished working versions", + "orchestrations.list.title": "Ereignis-Orchestrierungen", + "orchestrations.list.items": "orchestrations", + "orchestrations.actions.create": "Neue Orchestrierung", + "orchestrations.actions.reload": "Neu laden", + "orchestrations.actions.open": "Öffnen", + "orchestrations.actions.back": "Zurück", + "orchestrations.actions.save_draft": "Entwurf speichern", + "orchestrations.actions.validate": "Validieren", + "orchestrations.actions.publish": "Veröffentlichen", + "orchestrations.actions.add_rule": "Regel hinzufügen", + "orchestrations.actions.json_view": "JSON-Ansicht", + "orchestrations.actions.builder_view": "Builder", + "orchestrations.actions.apply_json": "JSON anwenden", + "orchestrations.actions.format_json": "JSON formatieren", + "orchestrations.actions.run_simulation": "Simulation starten", + "orchestrations.actions.view": "Anzeigen", + "orchestrations.actions.rollback": "Zurückrollen", + "orchestrations.actions.trace": "Trace", + "orchestrations.actions.add_webhook": "Webhook hinzufügen", + "orchestrations.actions.save": "Speichern", + "orchestrations.actions.save_runtime": "Runtime speichern", + "orchestrations.actions.delete": "Löschen", + "orchestrations.actions.edit": "Bearbeiten", + "orchestrations.actions.duplicate": "Duplizieren", + "orchestrations.actions.save_rule": "Regel speichern", + "orchestrations.search.placeholder": "Orchestrierungen suchen", + "orchestrations.filters.all_modes": "Alle Modi", + "orchestrations.filters.all_scopes": "Alle Bereiche", + "orchestrations.mode.active": "Aktiv", + "orchestrations.mode.shadow": "Schatten", + "orchestrations.mode.disabled": "Deaktiviert", + "orchestrations.scope.global": "Global", + "orchestrations.scope.service": "Service", + "orchestrations.table.name": "Name", + "orchestrations.table.scope": "Bereich", + "orchestrations.table.mode": "Modus", + "orchestrations.table.compatibility": "Kompatibilität", + "orchestrations.table.version": "Version", + "orchestrations.table.updated": "Aktualisiert", + "orchestrations.table.actions": "Aktionen", + "orchestrations.empty.loading": "Orchestrierungen werden geladen...", + "orchestrations.empty.none": "Keine Orchestrierungen gefunden", + "orchestrations.tabs.rules": "Regeln", + "orchestrations.tabs.simulator": "Simulator", + "orchestrations.tabs.versions": "Versionen", + "orchestrations.tabs.executions": "Ausführungen", + "orchestrations.tabs.webhooks": "Webhook-Aktionen", + "orchestrations.tabs.settings": "Einstellungen", + "orchestrations.rules.title": "Geordnete Regeln", + "orchestrations.rules.help": "Rules run from top to bottom. Use nested groups for AND, OR and NOT logic.", + "orchestrations.rules.definition_json": "Definition JSON", + "orchestrations.rules.empty": "Dieser Entwurf enthält noch keine Regeln.", + "orchestrations.rules.catch_all": "Matches every event", + "orchestrations.rules.no_actions": "No actions", + "orchestrations.rules.no_draft": "No draft", + "orchestrations.simulator.input": "Eingabeereignis", + "orchestrations.simulator.help": "Test a draft without creating alerts or executing webhooks.", + "orchestrations.simulator.source": "Input format", + "orchestrations.simulator.normalized": "Normalized event", + "orchestrations.simulator.payload": "Event JSON", + "orchestrations.simulator.compare_active": "Compare with active version", + "orchestrations.simulator.result": "Simulationsergebnis", + "orchestrations.simulator.result_help": "Full deterministic rule and action trace.", + "orchestrations.simulator.no_result": "Run a simulation to see the result.", + "orchestrations.versions.title": "Versionsverlauf", + "orchestrations.versions.status": "Status", + "orchestrations.versions.comment": "Comment", + "orchestrations.versions.hash": "Definition hash", + "orchestrations.versions.published": "Published", + "orchestrations.versions.selected_definition": "Selected definition", + "orchestrations.versions.active_diff": "Diff against active", + "orchestrations.versions.no_diff": "No differences", + "orchestrations.executions.title": "Ausführungsverlauf", + "orchestrations.executions.source": "Source", + "orchestrations.executions.disposition": "Disposition", + "orchestrations.executions.matches": "Matched rules", + "orchestrations.executions.duration": "Duration", + "orchestrations.executions.created": "Created", + "orchestrations.webhooks.title": "Webhook-Aktionen", + "orchestrations.webhooks.help": "Reusable encrypted outbound actions executed asynchronously.", + "orchestrations.webhooks.name": "Name", + "orchestrations.webhooks.method": "Method", + "orchestrations.webhooks.retry": "Retries", + "orchestrations.webhooks.status": "Status", + "orchestrations.webhooks.editor_title": "Webhook action", + "orchestrations.webhooks.secret_help": "Headers are encrypted and are never returned by the API.", + "orchestrations.webhooks.headers": "Secret headers JSON", + "orchestrations.webhooks.body": "Body template", + "orchestrations.webhooks.timeout": "Timeout, seconds", + "orchestrations.webhooks.delete_title": "Delete webhook action", + "orchestrations.webhooks.delete_message": "This action will no longer be available to orchestration rules.", + "orchestrations.settings.metadata": "Metadaten", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "A published version is required before active or shadow mode can be enabled.", + "orchestrations.form.name": "Name", + "orchestrations.form.description": "Beschreibung", + "orchestrations.form.scope": "Bereich", + "orchestrations.form.service": "Service", + "orchestrations.form.compatibility": "Kompatibilitätsmodus", + "orchestrations.form.mode": "Runtime-Modus", + "orchestrations.create.title": "Ereignis-Orchestrierung erstellen", + "orchestrations.create.help": "Create a disabled orchestration with an initial draft.", + "orchestrations.rule_editor.title": "Regel-Editor", + "orchestrations.rule_editor.help": "Build conditions and actions without writing JSON.", + "orchestrations.rule_editor.processing_mode": "After match", + "orchestrations.rule_editor.enabled": "Aktiviert", + "orchestrations.rule_editor.disabled": "Deaktiviert", + "orchestrations.rule_editor.conditions": "Bedingungen", + "orchestrations.rule_editor.condition": "Bedingung", + "orchestrations.rule_editor.group": "Gruppe", + "orchestrations.rule_editor.actions": "Aktionen", + "orchestrations.rule_editor.action": "Aktion", + "orchestrations.validation.valid": "Der Entwurf ist gültig.", + "orchestrations.publish.title": "Publish draft", + "orchestrations.publish.message": "Publish this immutable version and make it active? Catch-all drop rules require explicit confirmation.", + "orchestrations.rollback.title": "Rollback version", + "orchestrations.rollback.message": "Publish a new immutable copy of this historical version?", + "orchestrations.delete.title": "Delete orchestration", + "orchestrations.delete.message": "Disable and archive this orchestration?", + "orchestrations.errors.invalid_json": "Ungültiges JSON", + "orchestrations.errors.rules_required": "Die Definition muss ein rules-Array enthalten", + "orchestrations.webhooks.private_network_policy": "Richtlinie für private Netzwerke" +} diff --git a/app/static/i18n/en/orchestrations.json b/app/static/i18n/en/orchestrations.json new file mode 100644 index 0000000..1e9952b --- /dev/null +++ b/app/static/i18n/en/orchestrations.json @@ -0,0 +1,135 @@ +{ + "nav.event_orchestration": "Event Orchestration", + "pages.orchestrations.title": "Event Orchestration", + "pages.orchestrations.subtitle": "Route, enrich, suppress and automate incoming events", + "orchestrations.summary.total": "Orchestrations", + "orchestrations.summary.total_hint": "Definitions in the selected group", + "orchestrations.summary.active": "Active", + "orchestrations.summary.active_hint": "Applied to production events", + "orchestrations.summary.shadow": "Shadow", + "orchestrations.summary.shadow_hint": "Evaluated without changing behavior", + "orchestrations.summary.drafts": "Drafts", + "orchestrations.summary.drafts_hint": "Unpublished working versions", + "orchestrations.list.title": "Event orchestrations", + "orchestrations.list.items": "orchestrations", + "orchestrations.actions.create": "New orchestration", + "orchestrations.actions.reload": "Reload", + "orchestrations.actions.open": "Open", + "orchestrations.actions.back": "Back", + "orchestrations.actions.save_draft": "Save draft", + "orchestrations.actions.validate": "Validate", + "orchestrations.actions.publish": "Publish", + "orchestrations.actions.add_rule": "Add rule", + "orchestrations.actions.json_view": "JSON view", + "orchestrations.actions.builder_view": "Builder", + "orchestrations.actions.apply_json": "Apply JSON", + "orchestrations.actions.format_json": "Format JSON", + "orchestrations.actions.run_simulation": "Run simulation", + "orchestrations.actions.view": "View", + "orchestrations.actions.rollback": "Rollback", + "orchestrations.actions.trace": "Trace", + "orchestrations.actions.add_webhook": "Add webhook", + "orchestrations.actions.save": "Save", + "orchestrations.actions.save_runtime": "Save runtime", + "orchestrations.actions.delete": "Delete", + "orchestrations.actions.edit": "Edit", + "orchestrations.actions.duplicate": "Duplicate", + "orchestrations.actions.save_rule": "Save rule", + "orchestrations.search.placeholder": "Search orchestrations", + "orchestrations.filters.all_modes": "All modes", + "orchestrations.filters.all_scopes": "All scopes", + "orchestrations.mode.active": "Active", + "orchestrations.mode.shadow": "Shadow", + "orchestrations.mode.disabled": "Disabled", + "orchestrations.scope.global": "Global", + "orchestrations.scope.service": "Service", + "orchestrations.table.name": "Name", + "orchestrations.table.scope": "Scope", + "orchestrations.table.mode": "Mode", + "orchestrations.table.compatibility": "Compatibility", + "orchestrations.table.version": "Version", + "orchestrations.table.updated": "Updated", + "orchestrations.table.actions": "Actions", + "orchestrations.empty.loading": "Loading orchestrations...", + "orchestrations.empty.none": "No orchestrations found", + "orchestrations.tabs.rules": "Rules", + "orchestrations.tabs.simulator": "Simulator", + "orchestrations.tabs.versions": "Versions", + "orchestrations.tabs.executions": "Executions", + "orchestrations.tabs.webhooks": "Webhook Actions", + "orchestrations.tabs.settings": "Settings", + "orchestrations.rules.title": "Ordered rules", + "orchestrations.rules.help": "Rules run from top to bottom. Use nested groups for AND, OR and NOT logic.", + "orchestrations.rules.definition_json": "Definition JSON", + "orchestrations.rules.empty": "No rules in this draft yet.", + "orchestrations.rules.catch_all": "Matches every event", + "orchestrations.rules.no_actions": "No actions", + "orchestrations.rules.no_draft": "No draft", + "orchestrations.simulator.input": "Input event", + "orchestrations.simulator.help": "Test a draft without creating alerts or executing webhooks.", + "orchestrations.simulator.source": "Input format", + "orchestrations.simulator.normalized": "Normalized event", + "orchestrations.simulator.payload": "Event JSON", + "orchestrations.simulator.compare_active": "Compare with active version", + "orchestrations.simulator.result": "Simulation result", + "orchestrations.simulator.result_help": "Full deterministic rule and action trace.", + "orchestrations.simulator.no_result": "Run a simulation to see the result.", + "orchestrations.versions.title": "Version history", + "orchestrations.versions.status": "Status", + "orchestrations.versions.comment": "Comment", + "orchestrations.versions.hash": "Definition hash", + "orchestrations.versions.published": "Published", + "orchestrations.versions.selected_definition": "Selected definition", + "orchestrations.versions.active_diff": "Diff against active", + "orchestrations.versions.no_diff": "No differences", + "orchestrations.executions.title": "Execution history", + "orchestrations.executions.source": "Source", + "orchestrations.executions.disposition": "Disposition", + "orchestrations.executions.matches": "Matched rules", + "orchestrations.executions.duration": "Duration", + "orchestrations.executions.created": "Created", + "orchestrations.webhooks.title": "Webhook actions", + "orchestrations.webhooks.help": "Reusable encrypted outbound actions executed asynchronously.", + "orchestrations.webhooks.name": "Name", + "orchestrations.webhooks.method": "Method", + "orchestrations.webhooks.retry": "Retries", + "orchestrations.webhooks.status": "Status", + "orchestrations.webhooks.editor_title": "Webhook action", + "orchestrations.webhooks.secret_help": "Headers are encrypted and are never returned by the API.", + "orchestrations.webhooks.headers": "Secret headers JSON", + "orchestrations.webhooks.body": "Body template", + "orchestrations.webhooks.timeout": "Timeout, seconds", + "orchestrations.webhooks.delete_title": "Delete webhook action", + "orchestrations.webhooks.delete_message": "This action will no longer be available to orchestration rules.", + "orchestrations.settings.metadata": "Metadata", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "A published version is required before active or shadow mode can be enabled.", + "orchestrations.form.name": "Name", + "orchestrations.form.description": "Description", + "orchestrations.form.scope": "Scope", + "orchestrations.form.service": "Service", + "orchestrations.form.compatibility": "Compatibility mode", + "orchestrations.form.mode": "Runtime mode", + "orchestrations.create.title": "Create event orchestration", + "orchestrations.create.help": "Create a disabled orchestration with an initial draft.", + "orchestrations.rule_editor.title": "Rule editor", + "orchestrations.rule_editor.help": "Build conditions and actions without writing JSON.", + "orchestrations.rule_editor.processing_mode": "After match", + "orchestrations.rule_editor.enabled": "Enabled", + "orchestrations.rule_editor.disabled": "Disabled", + "orchestrations.rule_editor.conditions": "Conditions", + "orchestrations.rule_editor.condition": "Condition", + "orchestrations.rule_editor.group": "Group", + "orchestrations.rule_editor.actions": "Actions", + "orchestrations.rule_editor.action": "Action", + "orchestrations.validation.valid": "Draft is valid.", + "orchestrations.publish.title": "Publish draft", + "orchestrations.publish.message": "Publish this immutable version and make it active? Catch-all drop rules require explicit confirmation.", + "orchestrations.rollback.title": "Rollback version", + "orchestrations.rollback.message": "Publish a new immutable copy of this historical version?", + "orchestrations.delete.title": "Delete orchestration", + "orchestrations.delete.message": "Disable and archive this orchestration?", + "orchestrations.errors.invalid_json": "Invalid JSON", + "orchestrations.errors.rules_required": "Definition must contain a rules array", + "orchestrations.webhooks.private_network_policy": "Private network policy" +} diff --git a/app/static/i18n/ru/orchestrations.json b/app/static/i18n/ru/orchestrations.json new file mode 100644 index 0000000..4728e8d --- /dev/null +++ b/app/static/i18n/ru/orchestrations.json @@ -0,0 +1,135 @@ +{ + "nav.event_orchestration": "Оркестрация событий", + "pages.orchestrations.title": "Оркестрация событий", + "pages.orchestrations.subtitle": "Маршрутизация, обогащение, подавление и автоматизация входящих событий", + "orchestrations.summary.total": "Оркестрации", + "orchestrations.summary.total_hint": "Определения в выбранной группе", + "orchestrations.summary.active": "Активные", + "orchestrations.summary.active_hint": "Применяются к production-событиям", + "orchestrations.summary.shadow": "Теневые", + "orchestrations.summary.shadow_hint": "Проверяются без изменения поведения", + "orchestrations.summary.drafts": "Черновики", + "orchestrations.summary.drafts_hint": "Неопубликованные рабочие версии", + "orchestrations.list.title": "Оркестрации событий", + "orchestrations.list.items": "оркестраций", + "orchestrations.actions.create": "Новая оркестрация", + "orchestrations.actions.reload": "Обновить", + "orchestrations.actions.open": "Открыть", + "orchestrations.actions.back": "Назад", + "orchestrations.actions.save_draft": "Сохранить черновик", + "orchestrations.actions.validate": "Проверить", + "orchestrations.actions.publish": "Опубликовать", + "orchestrations.actions.add_rule": "Добавить правило", + "orchestrations.actions.json_view": "JSON", + "orchestrations.actions.builder_view": "Конструктор", + "orchestrations.actions.apply_json": "Применить JSON", + "orchestrations.actions.format_json": "Форматировать JSON", + "orchestrations.actions.run_simulation": "Запустить симуляцию", + "orchestrations.actions.view": "Просмотр", + "orchestrations.actions.rollback": "Откатить", + "orchestrations.actions.trace": "Трассировка", + "orchestrations.actions.add_webhook": "Добавить webhook", + "orchestrations.actions.save": "Сохранить", + "orchestrations.actions.save_runtime": "Сохранить режим", + "orchestrations.actions.delete": "Удалить", + "orchestrations.actions.edit": "Изменить", + "orchestrations.actions.duplicate": "Дублировать", + "orchestrations.actions.save_rule": "Сохранить правило", + "orchestrations.search.placeholder": "Поиск оркестраций", + "orchestrations.filters.all_modes": "Все режимы", + "orchestrations.filters.all_scopes": "Все области", + "orchestrations.mode.active": "Активна", + "orchestrations.mode.shadow": "Теневая", + "orchestrations.mode.disabled": "Отключена", + "orchestrations.scope.global": "Глобальная", + "orchestrations.scope.service": "Сервис", + "orchestrations.table.name": "Название", + "orchestrations.table.scope": "Область", + "orchestrations.table.mode": "Режим", + "orchestrations.table.compatibility": "Совместимость", + "orchestrations.table.version": "Версия", + "orchestrations.table.updated": "Обновлено", + "orchestrations.table.actions": "Действия", + "orchestrations.empty.loading": "Загрузка оркестраций...", + "orchestrations.empty.none": "Оркестрации не найдены", + "orchestrations.tabs.rules": "Правила", + "orchestrations.tabs.simulator": "Симулятор", + "orchestrations.tabs.versions": "Версии", + "orchestrations.tabs.executions": "Выполнения", + "orchestrations.tabs.webhooks": "Webhook-действия", + "orchestrations.tabs.settings": "Настройки", + "orchestrations.rules.title": "Упорядоченные правила", + "orchestrations.rules.help": "Правила выполняются сверху вниз. Используйте вложенные группы для AND, OR и NOT.", + "orchestrations.rules.definition_json": "JSON определения", + "orchestrations.rules.empty": "В черновике пока нет правил.", + "orchestrations.rules.catch_all": "Совпадает со всеми событиями", + "orchestrations.rules.no_actions": "Нет действий", + "orchestrations.rules.no_draft": "Нет черновика", + "orchestrations.simulator.input": "Входное событие", + "orchestrations.simulator.help": "Проверка черновика без создания алертов и вызова webhook.", + "orchestrations.simulator.source": "Формат входа", + "orchestrations.simulator.normalized": "Нормализованное событие", + "orchestrations.simulator.payload": "JSON события", + "orchestrations.simulator.compare_active": "Сравнить с активной версией", + "orchestrations.simulator.result": "Результат симуляции", + "orchestrations.simulator.result_help": "Полная детерминированная трассировка правил и действий.", + "orchestrations.simulator.no_result": "Запустите симуляцию, чтобы увидеть результат.", + "orchestrations.versions.title": "История версий", + "orchestrations.versions.status": "Статус", + "orchestrations.versions.comment": "Комментарий", + "orchestrations.versions.hash": "Хеш определения", + "orchestrations.versions.published": "Опубликовано", + "orchestrations.versions.selected_definition": "Выбранное определение", + "orchestrations.versions.active_diff": "Разница с активной", + "orchestrations.versions.no_diff": "Различий нет", + "orchestrations.executions.title": "История выполнений", + "orchestrations.executions.source": "Источник", + "orchestrations.executions.disposition": "Результат", + "orchestrations.executions.matches": "Совпавшие правила", + "orchestrations.executions.duration": "Длительность", + "orchestrations.executions.created": "Создано", + "orchestrations.webhooks.title": "Webhook-действия", + "orchestrations.webhooks.help": "Переиспользуемые зашифрованные исходящие действия, выполняемые асинхронно.", + "orchestrations.webhooks.name": "Название", + "orchestrations.webhooks.method": "Метод", + "orchestrations.webhooks.retry": "Повторы", + "orchestrations.webhooks.status": "Статус", + "orchestrations.webhooks.editor_title": "Webhook-действие", + "orchestrations.webhooks.secret_help": "Заголовки шифруются и никогда не возвращаются API.", + "orchestrations.webhooks.headers": "JSON секретных заголовков", + "orchestrations.webhooks.body": "Шаблон тела", + "orchestrations.webhooks.timeout": "Таймаут, секунд", + "orchestrations.webhooks.delete_title": "Удалить webhook-действие", + "orchestrations.webhooks.delete_message": "Действие больше не будет доступно правилам оркестрации.", + "orchestrations.settings.metadata": "Метаданные", + "orchestrations.settings.runtime": "Runtime", + "orchestrations.settings.runtime_help": "Для включения active или shadow необходима опубликованная версия.", + "orchestrations.form.name": "Название", + "orchestrations.form.description": "Описание", + "orchestrations.form.scope": "Область", + "orchestrations.form.service": "Сервис", + "orchestrations.form.compatibility": "Режим совместимости", + "orchestrations.form.mode": "Runtime-режим", + "orchestrations.create.title": "Создать оркестрацию событий", + "orchestrations.create.help": "Создаёт отключённую оркестрацию с начальным черновиком.", + "orchestrations.rule_editor.title": "Редактор правила", + "orchestrations.rule_editor.help": "Создавайте условия и действия без ручного JSON.", + "orchestrations.rule_editor.processing_mode": "После совпадения", + "orchestrations.rule_editor.enabled": "Включено", + "orchestrations.rule_editor.disabled": "Отключено", + "orchestrations.rule_editor.conditions": "Условия", + "orchestrations.rule_editor.condition": "Условие", + "orchestrations.rule_editor.group": "Группа", + "orchestrations.rule_editor.actions": "Действия", + "orchestrations.rule_editor.action": "Действие", + "orchestrations.validation.valid": "Черновик валиден.", + "orchestrations.publish.title": "Опубликовать черновик", + "orchestrations.publish.message": "Опубликовать неизменяемую версию и сделать её активной? Безусловный drop требует явного подтверждения.", + "orchestrations.rollback.title": "Откатить версию", + "orchestrations.rollback.message": "Опубликовать новую неизменяемую копию исторической версии?", + "orchestrations.delete.title": "Удалить оркестрацию", + "orchestrations.delete.message": "Отключить и архивировать эту оркестрацию?", + "orchestrations.errors.invalid_json": "Некорректный JSON", + "orchestrations.errors.rules_required": "Определение должно содержать массив rules", + "orchestrations.webhooks.private_network_policy": "Политика приватных сетей" +} diff --git a/app/static/js/core/state.js b/app/static/js/core/state.js index 790eb1c..788b4e5 100644 --- a/app/static/js/core/state.js +++ b/app/static/js/core/state.js @@ -10,6 +10,7 @@ const routes = { "/business-services": { page: "business-services", title: "Business Services", subtitle: "Customer-facing capabilities, business impact and status page services", load: function () { loadBusinessServices(); }}, "/heartbeats": { page: "heartbeats", title: "Heartbeats", subtitle: "Dead-man checks for jobs, monitoring pipelines and alert delivery paths", load: function () { loadHeartbeats(); }}, "/maintenance-windows": { page: "maintenance-windows", title: "Maintenance Windows", subtitle: "Planned maintenance, notification suppression and escalation handling", load: function () {loadMaintenanceWindows();}}, + "/event-orchestration": { page: "orchestrations", title: "Event Orchestration", subtitle: "Route, enrich, suppress and automate incoming events", load: function () { loadOrchestrations(); } }, "/escalation-policies": { page: "escalation-policies", title: "Escalation Policies", subtitle: "Define alert escalation chains by team", load: function () { loadEscalationPolicies(); } }, "/notification-policies": { page: "notification-policies", title: "Notification Policies", subtitle: "Select shared notification channels for service events", load: function () { loadNotificationPolicies(); }}, "/matcher-presets": { page: "matcher-presets", title: "Matcher Presets", subtitle: "Reusable alert matchers for service policies", load: function () { loadMatcherPresets(); } }, diff --git a/app/static/js/pages/orchestrations.js b/app/static/js/pages/orchestrations.js new file mode 100644 index 0000000..901bfb0 --- /dev/null +++ b/app/static/js/pages/orchestrations.js @@ -0,0 +1,962 @@ +let orchestrationItems = []; +let orchestrationCurrent = null; +let orchestrationCatalogCache = null; +let orchestrationDefinition = {schema_version: 1, rules: []}; +let orchestrationRuleBuffer = null; +let orchestrationRuleIndex = null; +let orchestrationVersions = []; +let orchestrationExecutions = []; +let orchestrationRulesView = "builder"; + +const ORCHESTRATION_CONDITION_OPERATORS = [ + "equals", "not_equals", "contains", "not_contains", "starts_with", + "ends_with", "regex", "not_regex", "in", "not_in", "exists", + "not_exists", "greater_than", "less_than", "greater_or_equal", + "less_or_equal", "is_true", "is_false" +]; + +const ORCHESTRATION_ACTION_TYPES = [ + "set_title", "set_message", "set_description", "set_severity", + "set_priority", "set_dedup_key", "set_group_key", "set_event_action", + "set_label", "remove_label", "set_custom_field", "remove_custom_field", + "set_team", "set_route", "set_service", "set_escalation_policy", + "set_notification_policy", "set_priority_policy", "set_grouping", + "add_note", "suppress", "pause", "drop", "enqueue_webhook" +]; + +function orchestrationGroupId() { + const active = Number($("#active-group-select").val() || 0); + if (active) { + return active; + } + if (currentUser && currentUser.active_group_id) { + return Number(currentUser.active_group_id); + } + const groups = asArray(currentUser && currentUser.groups); + return groups.length ? Number(groups[0].id) : null; +} + +function orchestrationClone(value) { + return JSON.parse(JSON.stringify(value === undefined ? null : value)); +} + +function orchestrationJson(value) { + return JSON.stringify(value, null, 2); +} + +function orchestrationParseJson(value, fallback) { + try { + return JSON.parse(value); + } catch (error) { + showAppError(error.message, i18n.t("orchestrations.errors.invalid_json")); + return fallback; + } +} + +function orchestrationIdFromLocation() { + const params = new URLSearchParams(window.location.search || ""); + const id = Number(params.get("orchestration_id") || 0); + return Number.isInteger(id) && id > 0 ? id : null; +} + +function updateOrchestrationLocation(id, options) { + if (!window.history || !window.history.pushState) { + return; + } + + const settings = $.extend({replace: false}, options || {}); + const url = new URL(window.location.href); + + if (id) { + url.searchParams.set("orchestration_id", String(id)); + } else { + url.searchParams.delete("orchestration_id"); + } + + const nextUrl = url.pathname + url.search + url.hash; + const currentUrl = window.location.pathname + window.location.search + window.location.hash; + + if (nextUrl === currentUrl) { + return; + } + + const state = {path: nextUrl, orchestration_id: id || null}; + if (settings.replace) { + window.history.replaceState(state, "", nextUrl); + } else { + window.history.pushState(state, "", nextUrl); + } +} + +function orchestrationOpenModal(id) { + $("#" + id).css("display", "flex"); +} + +function orchestrationCloseModal(id) { + $("#" + id).hide(); +} + +function orchestrationPermissions() { + return (orchestrationCurrent && orchestrationCurrent.permissions) || + (orchestrationCatalogCache && orchestrationCatalogCache.permissions) || {}; +} + +function orchestrationCan(permission) { + return !!orchestrationPermissions()[permission]; +} + +function orchestrationStatusBadge(mode) { + const badge = $("").addClass("status-badge").text( + i18n.t("orchestrations.mode." + mode, {}, mode) + ); + if (mode === "active") { + badge.addClass("status-active"); + } else if (mode === "shadow") { + badge.addClass("status-warning"); + } else { + badge.addClass("status-muted"); + } + return badge; +} + +function orchestrationScopeLabel(item) { + if (item.scope === "service") { + const service = asArray(orchestrationCatalogCache && orchestrationCatalogCache.services) + .find(function (candidate) { return Number(candidate.id) === Number(item.service_id); }); + return service ? service.name : i18n.t("orchestrations.scope.service"); + } + return i18n.t("orchestrations.scope.global"); +} + +function orchestrationFilteredItems() { + const query = String($("#orchestration-search").val() || "").trim().toLowerCase(); + const mode = $("#orchestration-mode-filter").val(); + const scope = $("#orchestration-scope-filter").val(); + return orchestrationItems.filter(function (item) { + if (mode && item.mode !== mode) { return false; } + if (scope && item.scope !== scope) { return false; } + if (!query) { return true; } + return [item.name, item.description, item.scope, item.mode, item.compatibility_mode] + .some(function (value) { return String(value || "").toLowerCase().includes(query); }); + }); +} + +function renderOrchestrationSummary() { + $("#orchestration-summary-total").text(orchestrationItems.length); + $("#orchestration-summary-active").text(orchestrationItems.filter(function (item) { return item.mode === "active"; }).length); + $("#orchestration-summary-shadow").text(orchestrationItems.filter(function (item) { return item.mode === "shadow"; }).length); + $("#orchestration-summary-drafts").text(orchestrationItems.filter(function (item) { return !!item.draft; }).length); +} + +function renderOrchestrationTable() { + const tbody = $("#orchestration-table").empty(); + const items = orchestrationFilteredItems(); + $("#orchestration-filtered-count").text(items.length); + $("#orchestration-total-count").text(orchestrationItems.length); + if (!items.length) { + tbody.append($("").append($("").attr("colspan", 7).addClass("empty-cell").text(i18n.t("orchestrations.empty.none")))); + return; + } + items.forEach(function (item) { + const name = $("
").append( + $("").text(item.name), + $("
").addClass("row-subtitle").text(item.description || "") + ); + const actions = $("
").addClass("table-actions"); + actions.append($("
+ + + {{ _('nav.event_orchestration') }} + + {{ _('nav.escalation_policies') }} @@ -257,6 +263,7 @@

{{ _('pages.dashboard.title') }}

{% include "pages/business_services.html" %} {% include "pages/heartbeats.html" %}
{% include "pages/maintenance_windows.html" %}
+
{% include "pages/orchestrations.html" %}
{% include "pages/escalation_policies.html" %}
{% include "pages/notification_policies.html" %}
{% include "pages/matcher_presets.html" %}
@@ -354,6 +361,7 @@

{{ _('common.message') }}

+ diff --git a/app/templates/pages/orchestrations.html b/app/templates/pages/orchestrations.html new file mode 100644 index 0000000..2d80ec6 --- /dev/null +++ b/app/templates/pages/orchestrations.html @@ -0,0 +1,139 @@ +
+
+
{{ _('orchestrations.summary.total') }}
0
{{ _('orchestrations.summary.total_hint') }}
+
{{ _('orchestrations.summary.active') }}
0
{{ _('orchestrations.summary.active_hint') }}
+
{{ _('orchestrations.summary.shadow') }}
0
{{ _('orchestrations.summary.shadow_hint') }}
+
{{ _('orchestrations.summary.drafts') }}
0
{{ _('orchestrations.summary.drafts_hint') }}
+
+ +
+
+

{{ _('orchestrations.list.title') }}

0 / 0 {{ _('orchestrations.list.items') }}
+
+
+
+ + + +
+
{{ _('orchestrations.table.name') }}{{ _('orchestrations.table.scope') }}{{ _('orchestrations.table.mode') }}{{ _('orchestrations.table.compatibility') }}{{ _('orchestrations.table.version') }}{{ _('orchestrations.table.updated') }}{{ _('orchestrations.table.actions') }}
{{ _('orchestrations.empty.loading') }}
+
+ + +
+ + + + + + + + + + diff --git a/app/views/orchestrations_view.py b/app/views/orchestrations_view.py index 2a50d50..7f05f65 100644 --- a/app/views/orchestrations_view.py +++ b/app/views/orchestrations_view.py @@ -1,24 +1,198 @@ -"""Event Orchestration simulator, replay and execution APIs.""" +"""Event Orchestration control-plane, simulator and replay APIs.""" + +from __future__ import annotations + +from typing import Any from flask import Blueprint, jsonify, request +from peewee import IntegrityError from app.api.schemas.orchestrations import ( + OrchestrationCreateSchema, + OrchestrationDraftSchema, + OrchestrationPublishSchema, OrchestrationReplaySchema, + OrchestrationRollbackSchema, + OrchestrationRuntimeSchema, OrchestrationSimulationSchema, + OrchestrationUpdateSchema, + OrchestrationWebhookActionCreateSchema, + OrchestrationWebhookActionUpdateSchema, +) +from app.modules.db import orchestrations_repo +from app.modules.db.models import ( + AlertRoute, + AutomationExecution, + EscalationPolicy, + EventOrchestration, + EventOrchestrationVersion, + Group, + NotificationPolicy, + OrchestrationWebhookAction, + PriorityPolicy, + Service, + Team, ) from app.services.audit import write_audit -from app.services.orchestration import simulator +from app.services.orchestration import simulator, webhooks +from app.services.integrations.normalizers.registry import SUPPORTED_NORMALIZER_SOURCES from app.services.orchestration.permissions import ( + CREATE, + DELETE, + EDIT, + MANAGE_ACTIONS, + PUBLISH, REPLAY, SIMULATE, + VIEW, VIEW_EXECUTIONS, has_orchestration_permission, ) -from app.services.rbac import current_user +from app.services.rbac import current_user, get_allowed_group_ids from app.services.validation import make_error_response, validate_body orchestrations_bp = Blueprint("event_orchestrations_api", __name__) +orchestration_webhook_actions_bp = Blueprint( + "orchestration_webhook_actions_api", + __name__, +) + + +def _iso(value): + return value.isoformat() if value is not None and hasattr(value, "isoformat") else value + + +def _serialize_webhook_action(action): + payload = webhooks.serialize_webhook_action(action) + payload["created_at"] = _iso(payload.get("created_at")) + payload["updated_at"] = _iso(payload.get("updated_at")) + return payload + + +def _permission_map(user, group_id: int) -> dict[str, bool]: + return { + "view": has_orchestration_permission(user, group_id, VIEW), + "create": has_orchestration_permission(user, group_id, CREATE), + "edit": has_orchestration_permission(user, group_id, EDIT), + "publish": has_orchestration_permission(user, group_id, PUBLISH), + "delete": has_orchestration_permission(user, group_id, DELETE), + "simulate": has_orchestration_permission(user, group_id, SIMULATE), + "replay": has_orchestration_permission(user, group_id, REPLAY), + "view_executions": has_orchestration_permission( + user, + group_id, + VIEW_EXECUTIONS, + ), + "manage_actions": has_orchestration_permission( + user, + group_id, + MANAGE_ACTIONS, + ), + } + + +def _serialize_version(version, *, include_definition=False): + payload = { + "id": version.id, + "orchestration_id": version.orchestration_id, + "version_number": version.version_number, + "status": version.status, + "definition_hash": version.definition_hash, + "comment": version.comment, + "created_by_id": version.created_by_id, + "published_by_id": version.published_by_id, + "created_at": _iso(version.created_at), + "updated_at": _iso(version.updated_at), + "published_at": _iso(version.published_at), + } + if include_definition: + payload["definition"] = orchestrations_repo.export_version(version.id) + return payload + + +def _serialize_orchestration(orchestration, *, include_definition=False): + user = current_user() + draft = orchestrations_repo.get_draft(orchestration.id) + active = None + if orchestration.active_version_id: + active = EventOrchestrationVersion.get_or_none( + EventOrchestrationVersion.id == orchestration.active_version_id + ) + payload = { + "id": orchestration.id, + "uid": str(orchestration.uid), + "group_id": orchestration.group_id, + "name": orchestration.name, + "description": orchestration.description, + "scope": orchestration.scope, + "service_id": orchestration.service_id, + "enabled": bool(orchestration.enabled), + "mode": orchestration.mode, + "compatibility_mode": orchestration.compatibility_mode, + "active_version_id": orchestration.active_version_id, + "active_version": _serialize_version(active) if active else None, + "draft": _serialize_version(draft, include_definition=include_definition) + if draft + else None, + "created_by_id": orchestration.created_by_id, + "created_at": _iso(orchestration.created_at), + "updated_at": _iso(orchestration.updated_at), + "permissions": _permission_map(user, orchestration.group_id), + } + if include_definition and active: + payload["active_definition"] = orchestrations_repo.export_version(active.id) + return payload + + +def _require_group_permission(group_id: int, permission: str): + user = current_user() + if user is None or not has_orchestration_permission(user, group_id, permission): + return None, make_error_response( + "forbidden", + "You do not have permission to perform this orchestration action.", + 403, + ) + return user, None + + +def _require_permission(orchestration_id, permission): + try: + orchestration = orchestrations_repo.get_orchestration(orchestration_id) + except orchestrations_repo.OrchestrationNotFound: + return None, make_error_response( + "orchestration_not_found", + "Event orchestration was not found.", + 404, + ) + user, error = _require_group_permission(orchestration.group_id, permission) + if error: + return None, error + return orchestration, None + + +def _repo_error_response(error): + if isinstance(error, orchestrations_repo.OrchestrationNotFound): + return make_error_response( + "orchestration_not_found", + str(error), + 404, + ) + if isinstance(error, orchestrations_repo.OrchestrationConflict): + return make_error_response( + "orchestration_conflict", + str(error), + 409, + ) + if isinstance(error, orchestrations_repo.OrchestrationValidationError): + return make_error_response( + "orchestration_validation_error", + str(error), + 400, + errors=error.errors, + warnings=error.warnings, + ) + return make_error_response("invalid_request", "Invalid request.", 400) @orchestrations_bp.errorhandler(simulator.OrchestrationSimulationError) @@ -35,20 +209,390 @@ def handle_orchestration_simulation_error(error): return jsonify({"error": code, "message": str(error)}), status -def _require_permission(orchestration_id, permission): - orchestration = simulator.get_orchestration(orchestration_id) +@orchestrations_bp.route("", methods=["GET"]) +@orchestrations_bp.route("/", methods=["GET"]) +def list_orchestrations(): user = current_user() - if user is None or not has_orchestration_permission( - user, - orchestration.group_id, - permission, - ): - return None, make_error_response( - "forbidden", - "You do not have permission to perform this orchestration action.", - 403, + allowed_group_ids = get_allowed_group_ids(user=user, use_active_group=False) + group_id = request.args.get("group_id", type=int) + if group_id is not None: + if group_id not in allowed_group_ids or not has_orchestration_permission( + user, + group_id, + VIEW, + ): + return make_error_response("forbidden", "Access to this group is denied", 403) + group_ids = [group_id] + else: + group_ids = [ + value + for value in allowed_group_ids + if has_orchestration_permission(user, value, VIEW) + ] + items = orchestrations_repo.list_orchestrations(group_ids=group_ids) + return jsonify( + { + "items": [_serialize_orchestration(item) for item in items], + "count": len(items), + } + ) + + +@orchestrations_bp.route("", methods=["POST"]) +@orchestrations_bp.route("/", methods=["POST"]) +def create_orchestration(): + payload, error = validate_body(OrchestrationCreateSchema) + if error: + return error + user, error = _require_group_permission(payload.group_id, CREATE) + if error: + return error + try: + orchestration = orchestrations_repo.create_orchestration( + group_id=payload.group_id, + name=payload.name, + description=payload.description, + scope=payload.scope, + service_id=payload.service_id, + compatibility_mode=payload.compatibility_mode, + created_by_id=user.id, ) - return orchestration, None + draft = orchestrations_repo.get_or_create_draft( + orchestration.id, + actor_id=user.id, + comment="Initial draft", + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.create", + object_type="event_orchestration", + object_id=orchestration.id, + group_id=orchestration.group_id, + data={"scope": orchestration.scope, "draft_version_id": draft.id}, + ) + return jsonify(_serialize_orchestration(orchestration, include_definition=True)), 201 + + +@orchestrations_bp.route("/", methods=["GET"]) +def get_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, VIEW) + if error: + return error + return jsonify(_serialize_orchestration(orchestration, include_definition=True)) + + +@orchestrations_bp.route("/", methods=["PATCH"]) +def update_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, EDIT) + if error: + return error + payload, error = validate_body(OrchestrationUpdateSchema) + if error: + return error + values = payload.model_dump(exclude_unset=True) + try: + orchestration = orchestrations_repo.update_orchestration( + orchestration.id, + name=values.get("name"), + description=values.get("description"), + description_provided="description" in values, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.update", + object_type="event_orchestration", + object_id=orchestration.id, + group_id=orchestration.group_id, + data={"fields": sorted(values)}, + ) + return jsonify(_serialize_orchestration(orchestration, include_definition=True)) + + +@orchestrations_bp.route("/", methods=["DELETE"]) +def delete_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, DELETE) + if error: + return error + try: + archived = orchestrations_repo.archive_orchestration(orchestration.id) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.delete", + object_type="event_orchestration", + object_id=archived.id, + group_id=archived.group_id, + ) + return jsonify({"deleted": True, "id": archived.id}) + + +@orchestrations_bp.route("//draft", methods=["POST"]) +def create_orchestration_draft(orchestration_id): + orchestration, error = _require_permission(orchestration_id, EDIT) + if error: + return error + user = current_user() + try: + draft = orchestrations_repo.get_or_create_draft( + orchestration.id, + actor_id=user.id, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + return jsonify(_serialize_version(draft, include_definition=True)) + + +@orchestrations_bp.route("//draft", methods=["PUT"]) +def save_orchestration_draft(orchestration_id): + orchestration, error = _require_permission(orchestration_id, EDIT) + if error: + return error + payload, error = validate_body(OrchestrationDraftSchema) + if error: + return error + user = current_user() + try: + draft = orchestrations_repo.save_draft_definition( + orchestration.id, + payload.rules, + actor_id=user.id, + comment=payload.comment, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.draft_saved", + object_type="event_orchestration_version", + object_id=draft.id, + group_id=orchestration.group_id, + data={"orchestration_id": orchestration.id, "rule_count": len(payload.rules)}, + ) + return jsonify(_serialize_version(draft, include_definition=True)) + + +@orchestrations_bp.route("//validate", methods=["POST"]) +def validate_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, EDIT) + if error: + return error + draft = orchestrations_repo.get_draft(orchestration.id) + if draft is None: + return make_error_response( + "orchestration_conflict", + "Orchestration has no draft to validate.", + 409, + ) + result = orchestrations_repo.validate_version(draft.id) + return jsonify(result) + + +@orchestrations_bp.route("//publish", methods=["POST"]) +def publish_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, PUBLISH) + if error: + return error + payload, error = validate_body(OrchestrationPublishSchema) + if error: + return error + user = current_user() + try: + version = orchestrations_repo.publish_draft( + orchestration.id, + actor_id=user.id, + comment=payload.comment, + confirm_catch_all_drop=payload.confirm_catch_all_drop, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.publish", + object_type="event_orchestration_version", + object_id=version.id, + group_id=orchestration.group_id, + data={"orchestration_id": orchestration.id, "version": version.version_number}, + ) + return jsonify(_serialize_version(version, include_definition=True)) + + +@orchestrations_bp.route("//rollback", methods=["POST"]) +def rollback_orchestration(orchestration_id): + orchestration, error = _require_permission(orchestration_id, PUBLISH) + if error: + return error + payload, error = validate_body(OrchestrationRollbackSchema) + if error: + return error + user = current_user() + try: + version = orchestrations_repo.rollback_to_version( + orchestration.id, + payload.version_id, + actor_id=user.id, + comment=payload.comment, + confirm_catch_all_drop=payload.confirm_catch_all_drop, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.rollback", + object_type="event_orchestration_version", + object_id=version.id, + group_id=orchestration.group_id, + data={"orchestration_id": orchestration.id, "source_version_id": payload.version_id}, + ) + return jsonify(_serialize_version(version, include_definition=True)) + + +@orchestrations_bp.route("//runtime", methods=["PATCH"]) +def update_orchestration_runtime(orchestration_id): + orchestration, error = _require_permission(orchestration_id, PUBLISH) + if error: + return error + payload, error = validate_body(OrchestrationRuntimeSchema) + if error: + return error + try: + orchestration = orchestrations_repo.set_runtime_state( + orchestration.id, + enabled=payload.enabled, + mode=payload.mode, + compatibility_mode=payload.compatibility_mode, + ) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + write_audit( + "event_orchestration.runtime_update", + object_type="event_orchestration", + object_id=orchestration.id, + group_id=orchestration.group_id, + data={"mode": orchestration.mode, "compatibility_mode": orchestration.compatibility_mode}, + ) + return jsonify(_serialize_orchestration(orchestration, include_definition=True)) + + +@orchestrations_bp.route("//versions", methods=["GET"]) +def list_orchestration_versions(orchestration_id): + orchestration, error = _require_permission(orchestration_id, VIEW) + if error: + return error + versions = orchestrations_repo.list_versions(orchestration.id) + return jsonify({"items": [_serialize_version(item) for item in versions]}) + + +@orchestrations_bp.route( + "//versions/", + methods=["GET"], +) +def get_orchestration_version(orchestration_id, version_id): + orchestration, error = _require_permission(orchestration_id, VIEW) + if error: + return error + try: + version = orchestrations_repo.get_version(orchestration.id, version_id) + except orchestrations_repo.OrchestrationError as exc: + return _repo_error_response(exc) + return jsonify(_serialize_version(version, include_definition=True)) + + +def _simple_entity(item, *, team_id=None): + return { + "id": item.id, + "name": getattr(item, "name", None) or getattr(item, "slug", None) or str(item.id), + "team_id": team_id if team_id is not None else getattr(item, "team_id", None), + "enabled": bool(getattr(item, "enabled", getattr(item, "active", True))), + } + + +@orchestrations_bp.route("/catalog", methods=["GET"]) +def orchestration_catalog(): + group_id = request.args.get("group_id", type=int) + if group_id is None: + return make_error_response("validation_error", "group_id is required", 400) + user, error = _require_group_permission(group_id, VIEW) + if error: + return error + group = Group.get_or_none(Group.id == group_id) + if group is None: + return make_error_response("group_not_found", "Group was not found.", 404) + team_query = Team.select().where( + (Team.group == group_id) + & (Team.deleted == False) # noqa: E712 + & Team.deleted_at.is_null(True) + ).order_by(Team.name.asc()) + teams = list(team_query) + team_ids = [team.id for team in teams] + + service_query = Service.select().where( + (Service.group == group_id) + & (Service.deleted == False) # noqa: E712 + & Service.deleted_at.is_null(True) + ).order_by(Service.name.asc()) + routes = [] + escalation = [] + notification = [] + priority = [] + if team_ids: + routes = list( + AlertRoute.select() + .where( + (AlertRoute.team.in_(team_ids)) + & (AlertRoute.deleted == False) # noqa: E712 + & AlertRoute.deleted_at.is_null(True) + ) + .order_by(AlertRoute.name.asc()) + ) + escalation = list( + EscalationPolicy.select() + .where( + (EscalationPolicy.team.in_(team_ids)) + & (EscalationPolicy.deleted == False) # noqa: E712 + & EscalationPolicy.deleted_at.is_null(True) + ) + .order_by(EscalationPolicy.name.asc()) + ) + notification = list( + NotificationPolicy.select() + .where( + (NotificationPolicy.team.in_(team_ids)) + & (NotificationPolicy.deleted == False) # noqa: E712 + & NotificationPolicy.deleted_at.is_null(True) + ) + .order_by(NotificationPolicy.name.asc()) + ) + priority = list( + PriorityPolicy.select() + .where( + (PriorityPolicy.team.in_(team_ids)) + & (PriorityPolicy.deleted == False) # noqa: E712 + & PriorityPolicy.deleted_at.is_null(True) + ) + .order_by(PriorityPolicy.name.asc()) + ) + actions = list( + OrchestrationWebhookAction.select() + .where( + (OrchestrationWebhookAction.group == group_id) + & (OrchestrationWebhookAction.deleted == False) # noqa: E712 + & OrchestrationWebhookAction.deleted_at.is_null(True) + ) + .order_by(OrchestrationWebhookAction.name.asc()) + ) + return jsonify( + { + "group": {"id": group_id, "name": group.name if group else str(group_id)}, + "permissions": _permission_map(user, group_id), + "teams": [_simple_entity(item) for item in teams], + "services": [_simple_entity(item) for item in service_query], + "routes": [_simple_entity(item) for item in routes], + "escalation_policies": [_simple_entity(item) for item in escalation], + "notification_policies": [_simple_entity(item) for item in notification], + "priority_policies": [_simple_entity(item) for item in priority], + "webhook_actions": [_serialize_webhook_action(item) for item in actions], + "normalizer_sources": list(SUPPORTED_NORMALIZER_SOURCES), + } + ) @orchestrations_bp.route("//simulate", methods=["POST"]) @@ -56,11 +600,9 @@ def simulate_orchestration(orchestration_id): orchestration, error = _require_permission(orchestration_id, SIMULATE) if error: return error - payload, error = validate_body(OrchestrationSimulationSchema) if error: return error - if payload.normalized_event is not None: event = simulator.prepare_normalized_event(payload.normalized_event) selected_normalizer = "normalized" @@ -74,7 +616,6 @@ def simulate_orchestration(orchestration_id): event_index=payload.event_index, ) ) - result = simulator.simulate_event( orchestration.id, event, @@ -83,7 +624,6 @@ def simulate_orchestration(orchestration_id): selected_normalizer=selected_normalizer, normalized_event_count=normalized_event_count, ) - write_audit( "event_orchestration.simulate", object_type="event_orchestration", @@ -103,11 +643,9 @@ def replay_orchestration(orchestration_id): orchestration, error = _require_permission(orchestration_id, REPLAY) if error: return error - payload, error = validate_body(OrchestrationReplaySchema) if error: return error - result = simulator.replay_events( orchestration.id, alert_ids=payload.alert_ids, @@ -132,13 +670,9 @@ def replay_orchestration(orchestration_id): @orchestrations_bp.route("//executions", methods=["GET"]) def list_orchestration_executions(orchestration_id): - orchestration, error = _require_permission( - orchestration_id, - VIEW_EXECUTIONS, - ) + orchestration, error = _require_permission(orchestration_id, VIEW_EXECUTIONS) if error: return error - limit = request.args.get("limit", default=50, type=int) include_trace = request.args.get("include_trace") == "1" return jsonify( @@ -152,17 +686,158 @@ def list_orchestration_executions(orchestration_id): @orchestrations_bp.route("//shadow-metrics", methods=["GET"]) def get_orchestration_shadow_metrics(orchestration_id): - orchestration, error = _require_permission( - orchestration_id, - VIEW_EXECUTIONS, + orchestration, error = _require_permission(orchestration_id, VIEW_EXECUTIONS) + if error: + return error + limit = request.args.get("limit", default=None, type=int) + return jsonify(simulator.shadow_metrics(orchestration.id, limit=limit)) + + +def _get_webhook_action(action_id): + return OrchestrationWebhookAction.get_or_none( + (OrchestrationWebhookAction.id == action_id) + & (OrchestrationWebhookAction.deleted == False) # noqa: E712 + & OrchestrationWebhookAction.deleted_at.is_null(True) ) + + +@orchestration_webhook_actions_bp.route("", methods=["GET"]) +@orchestration_webhook_actions_bp.route("/", methods=["GET"]) +def list_webhook_actions(): + group_id = request.args.get("group_id", type=int) + if group_id is None: + return make_error_response("validation_error", "group_id is required", 400) + _, error = _require_group_permission(group_id, VIEW) if error: return error + items = list( + OrchestrationWebhookAction.select() + .where( + (OrchestrationWebhookAction.group == group_id) + & (OrchestrationWebhookAction.deleted == False) # noqa: E712 + & OrchestrationWebhookAction.deleted_at.is_null(True) + ) + .order_by(OrchestrationWebhookAction.name.asc()) + ) + return jsonify({"items": [_serialize_webhook_action(item) for item in items]}) - limit = request.args.get("limit", default=None, type=int) - return jsonify( - simulator.shadow_metrics( - orchestration.id, - limit=limit, + +@orchestration_webhook_actions_bp.route("", methods=["POST"]) +@orchestration_webhook_actions_bp.route("/", methods=["POST"]) +def create_webhook_action(): + payload, error = validate_body(OrchestrationWebhookActionCreateSchema) + if error: + return error + user, error = _require_group_permission(payload.group_id, MANAGE_ACTIONS) + if error: + return error + try: + action = webhooks.create_webhook_action( + **payload.model_dump(), + actor_id=user.id, + ) + except IntegrityError: + return make_error_response( + "webhook_action_conflict", + "A webhook action with this name already exists in the group.", + 409, + ) + except (webhooks.WebhookValidationError, ValueError) as exc: + return make_error_response("webhook_action_invalid", str(exc), 400) + write_audit( + "event_orchestration.webhook_action_create", + object_type="orchestration_webhook_action", + object_id=action.id, + group_id=action.group_id, + ) + return jsonify(_serialize_webhook_action(action)), 201 + + +@orchestration_webhook_actions_bp.route("/", methods=["PATCH"]) +def update_webhook_action(action_id): + action = _get_webhook_action(action_id) + if action is None: + return make_error_response("webhook_action_not_found", "Webhook action was not found.", 404) + _, error = _require_group_permission(action.group_id, MANAGE_ACTIONS) + if error: + return error + payload, error = validate_body(OrchestrationWebhookActionUpdateSchema) + if error: + return error + changes = payload.model_dump(exclude_unset=True) + try: + action = webhooks.update_webhook_action(action.id, **changes) + except IntegrityError: + return make_error_response( + "webhook_action_conflict", + "A webhook action with this name already exists in the group.", + 409, ) + except (webhooks.WebhookValidationError, ValueError) as exc: + return make_error_response("webhook_action_invalid", str(exc), 400) + write_audit( + "event_orchestration.webhook_action_update", + object_type="orchestration_webhook_action", + object_id=action.id, + group_id=action.group_id, + data={"fields": sorted(changes)}, + ) + return jsonify(_serialize_webhook_action(action)) + + +@orchestration_webhook_actions_bp.route("/", methods=["DELETE"]) +def delete_webhook_action(action_id): + action = _get_webhook_action(action_id) + if action is None: + return make_error_response("webhook_action_not_found", "Webhook action was not found.", 404) + _, error = _require_group_permission(action.group_id, MANAGE_ACTIONS) + if error: + return error + action.enabled = False + action.deleted = True + from app.modules.common import utc_now + + action.deleted_at = utc_now() + action.save() + write_audit( + "event_orchestration.webhook_action_delete", + object_type="orchestration_webhook_action", + object_id=action.id, + group_id=action.group_id, + ) + return jsonify({"deleted": True, "id": action.id}) + + +@orchestration_webhook_actions_bp.route("//executions", methods=["GET"]) +def list_webhook_action_executions(action_id): + action = _get_webhook_action(action_id) + if action is None: + return make_error_response("webhook_action_not_found", "Webhook action was not found.", 404) + _, error = _require_group_permission(action.group_id, VIEW_EXECUTIONS) + if error: + return error + limit = max(1, min(request.args.get("limit", default=50, type=int), 200)) + rows = list( + AutomationExecution.select() + .where(AutomationExecution.action == action.id) + .order_by(AutomationExecution.created_at.desc()) + .limit(limit) + ) + return jsonify( + { + "items": [ + { + "id": row.id, + "status": row.status, + "attempts": row.attempts, + "response_status": row.response_status, + "response_excerpt": row.response_excerpt_safe, + "error": row.error_safe, + "created_at": _iso(row.created_at), + "started_at": _iso(row.started_at), + "finished_at": _iso(row.finished_at), + } + for row in rows + ] + } ) diff --git a/app/views/pages_view.py b/app/views/pages_view.py index 5ee9bec..cbb473a 100644 --- a/app/views/pages_view.py +++ b/app/views/pages_view.py @@ -95,6 +95,8 @@ def pwa_service_worker(): @pages_bp.route("/heartbeats/") @pages_bp.route("/maintenance-windows") @pages_bp.route("/maintenance-windows/") +@pages_bp.route("/event-orchestration") +@pages_bp.route("/event-orchestration/") @pages_bp.route("/escalation-policies") @pages_bp.route("/escalation-policies/") @pages_bp.route("/notification-policies") diff --git a/tests/orchestration/test_orchestration_control_plane_api.py b/tests/orchestration/test_orchestration_control_plane_api.py new file mode 100644 index 0000000..110b78c --- /dev/null +++ b/tests/orchestration/test_orchestration_control_plane_api.py @@ -0,0 +1,289 @@ +import pytest + +from app.login import create_access_token +from app.modules.crypto import decrypt_json +from app.modules.db.models import ( + AutomationExecution, + EventOrchestration, + EventOrchestrationRule, + EventOrchestrationVersion, + OrchestrationExecution, + OrchestrationIntakeToken, + OrchestrationWebhookAction, + PendingOrchestratedEvent, +) +from tests.factories import create_group, create_user + + +@pytest.fixture(autouse=True) +def orchestration_control_plane_tables(db): + db.create_tables( + [ + EventOrchestration, + EventOrchestrationVersion, + EventOrchestrationRule, + OrchestrationIntakeToken, + OrchestrationExecution, + PendingOrchestratedEvent, + OrchestrationWebhookAction, + AutomationExecution, + ], + safe=True, + ) + AutomationExecution.delete().execute() + OrchestrationWebhookAction.delete().execute() + PendingOrchestratedEvent.delete().execute() + OrchestrationExecution.delete().execute() + EventOrchestrationRule.delete().execute() + EventOrchestrationVersion.delete().execute() + OrchestrationIntakeToken.delete().execute() + EventOrchestration.delete().execute() + yield + + +def _headers(user): + token, _ = create_access_token(user) + return {"Authorization": f"Bearer {token}"} + + +def _rule(name="critical"): + return { + "name": name, + "description": "Set incident severity", + "enabled": True, + "condition_tree": { + "all": [ + { + "field": "labels.environment", + "operator": "equals", + "value": "production", + } + ] + }, + "actions": [{"type": "set_severity", "value": "critical"}], + "processing_mode": "continue", + "children": [], + } + + +def test_admin_can_manage_full_orchestration_lifecycle(client, db, admin_headers): + group = create_group(slug="control-plane") + + created = client.post( + "/api/event-orchestrations", + headers=admin_headers, + json={ + "group_id": group.id, + "name": "Production routing", + "description": "Control-plane test", + "scope": "global", + "compatibility_mode": "legacy", + }, + ) + + assert created.status_code == 201 + orchestration = created.get_json() + orchestration_id = orchestration["id"] + assert orchestration["draft"]["definition"]["rules"] == [] + assert orchestration["permissions"]["publish"] is True + + saved = client.put( + f"/api/event-orchestrations/{orchestration_id}/draft", + headers=admin_headers, + json={"rules": [_rule()], "comment": "Ready for validation"}, + ) + + assert saved.status_code == 200 + saved_body = saved.get_json() + assert saved_body["comment"] == "Ready for validation" + assert saved_body["definition"]["rules"][0]["name"] == "critical" + + validation = client.post( + f"/api/event-orchestrations/{orchestration_id}/validate", + headers=admin_headers, + json={}, + ) + assert validation.status_code == 200 + assert validation.get_json()["valid"] is True + + published = client.post( + f"/api/event-orchestrations/{orchestration_id}/publish", + headers=admin_headers, + json={"comment": "Initial production version"}, + ) + assert published.status_code == 200 + published_body = published.get_json() + assert published_body["status"] == "published" + + runtime = client.patch( + f"/api/event-orchestrations/{orchestration_id}/runtime", + headers=admin_headers, + json={"mode": "shadow", "compatibility_mode": "hybrid"}, + ) + assert runtime.status_code == 200 + runtime_body = runtime.get_json() + assert runtime_body["enabled"] is True + assert runtime_body["mode"] == "shadow" + assert runtime_body["compatibility_mode"] == "hybrid" + + versions = client.get( + f"/api/event-orchestrations/{orchestration_id}/versions", + headers=admin_headers, + ) + assert versions.status_code == 200 + assert len(versions.get_json()["items"]) == 1 + + rollback = client.post( + f"/api/event-orchestrations/{orchestration_id}/rollback", + headers=admin_headers, + json={ + "version_id": published_body["id"], + "comment": "Rollback copy", + }, + ) + assert rollback.status_code == 200 + assert rollback.get_json()["version_number"] == 2 + assert rollback.get_json()["status"] == "published" + + deleted = client.delete( + f"/api/event-orchestrations/{orchestration_id}", + headers=admin_headers, + ) + assert deleted.status_code == 200 + assert deleted.get_json()["deleted"] is True + + missing = client.get( + f"/api/event-orchestrations/{orchestration_id}", + headers=admin_headers, + ) + assert missing.status_code == 404 + + +def test_editor_can_edit_but_cannot_publish(client, db): + group = create_group(slug="editor-group") + editor = create_user(group=group, group_role="editor") + headers = _headers(editor) + + created = client.post( + "/api/event-orchestrations", + headers=headers, + json={"group_id": group.id, "name": "Editor draft"}, + ) + assert created.status_code == 201 + orchestration_id = created.get_json()["id"] + + saved = client.put( + f"/api/event-orchestrations/{orchestration_id}/draft", + headers=headers, + json={"rules": [_rule()]}, + ) + assert saved.status_code == 200 + + denied = client.post( + f"/api/event-orchestrations/{orchestration_id}/publish", + headers=headers, + json={}, + ) + assert denied.status_code == 403 + assert denied.get_json()["error"] == "forbidden" + + +def test_list_and_get_do_not_cross_group_boundaries(client, db): + owner_group = create_group(slug="owner-control") + foreign_group = create_group(slug="foreign-control") + owner = create_user(group=owner_group, group_role="editor") + foreign = create_user(group=foreign_group, group_role="viewer") + + created = client.post( + "/api/event-orchestrations", + headers=_headers(owner), + json={"group_id": owner_group.id, "name": "Owner only"}, + ) + orchestration_id = created.get_json()["id"] + + listed = client.get( + f"/api/event-orchestrations?group_id={foreign_group.id}", + headers=_headers(foreign), + ) + assert listed.status_code == 200 + assert listed.get_json()["items"] == [] + + denied = client.get( + f"/api/event-orchestrations/{orchestration_id}", + headers=_headers(foreign), + ) + assert denied.status_code == 403 + + +def test_webhook_action_api_never_returns_secret_headers(client, db, admin_headers): + group = create_group(slug="webhook-control") + + created = client.post( + "/api/orchestration-webhook-actions", + headers=admin_headers, + json={ + "group_id": group.id, + "name": "Diagnostics", + "url": "https://hooks.example.test/diagnostics", + "method": "POST", + "headers": {"Authorization": "Bearer super-secret"}, + "body_template": '{"title":"{{ event.title }}"}', + "timeout_seconds": 10, + "retry_count": 2, + "private_network_policy": "deny", + }, + ) + + assert created.status_code == 201 + payload = created.get_json() + action_id = payload["id"] + serialized = str(payload) + assert payload["has_headers"] is True + assert "headers" not in payload + assert "super-secret" not in serialized + assert payload["created_at"] + + action = OrchestrationWebhookAction.get_by_id(action_id) + assert decrypt_json(action.headers_encrypted)["Authorization"] == "Bearer super-secret" + + updated = client.patch( + f"/api/orchestration-webhook-actions/{action_id}", + headers=admin_headers, + json={"name": "Diagnostics updated", "retry_count": 3}, + ) + assert updated.status_code == 200 + assert updated.get_json()["name"] == "Diagnostics updated" + action = OrchestrationWebhookAction.get_by_id(action_id) + assert decrypt_json(action.headers_encrypted)["Authorization"] == "Bearer super-secret" + + listed = client.get( + f"/api/orchestration-webhook-actions?group_id={group.id}", + headers=admin_headers, + ) + assert listed.status_code == 200 + assert len(listed.get_json()["items"]) == 1 + assert "super-secret" not in str(listed.get_json()) + + +def test_webhook_action_duplicate_name_returns_safe_conflict(client, db, admin_headers): + group = create_group(slug="webhook-conflict") + body = { + "group_id": group.id, + "name": "Duplicate", + "url": "https://hooks.example.test/duplicate", + } + assert client.post( + "/api/orchestration-webhook-actions", + headers=admin_headers, + json=body, + ).status_code == 201 + + duplicate = client.post( + "/api/orchestration-webhook-actions", + headers=admin_headers, + json=body, + ) + assert duplicate.status_code == 409 + payload = duplicate.get_json() + assert payload["error"] == "webhook_action_conflict" + assert "UNIQUE" not in str(payload).upper() diff --git a/tests/orchestration/test_orchestration_ui_assets.py b/tests/orchestration/test_orchestration_ui_assets.py new file mode 100644 index 0000000..41060b8 --- /dev/null +++ b/tests/orchestration/test_orchestration_ui_assets.py @@ -0,0 +1,76 @@ +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def test_orchestration_catalogs_have_matching_keys(): + catalogs = [] + for locale in ("en", "ru", "de"): + path = ROOT / "app" / "static" / "i18n" / locale / "orchestrations.json" + catalogs.append(json.loads(path.read_text(encoding="utf-8"))) + + assert set(catalogs[0]) == set(catalogs[1]) == set(catalogs[2]) + assert "orchestrations.webhooks.private_network_policy" in catalogs[0] + + +def test_orchestration_page_is_registered_with_assets(): + index = (ROOT / "app" / "templates" / "index.html").read_text(encoding="utf-8") + routes = (ROOT / "app" / "views" / "pages_view.py").read_text(encoding="utf-8") + state = (ROOT / "app" / "static" / "js" / "core" / "state.js").read_text(encoding="utf-8") + + assert 'pages/orchestrations.html' in index + assert 'js/pages/orchestrations.js' in index + assert 'css/orchestrations.css' in index + assert '@pages_bp.route("/event-orchestration")' in routes + assert '"/event-orchestration"' in state + + +def test_orchestration_ui_uses_shared_catalog_and_safe_api_surfaces(): + javascript = ( + ROOT / "app" / "static" / "js" / "pages" / "orchestrations.js" + ).read_text(encoding="utf-8") + template = ( + ROOT / "app" / "templates" / "pages" / "orchestrations.html" + ).read_text(encoding="utf-8") + + assert "/api/event-orchestrations/catalog" in javascript + assert "/api/orchestration-webhook-actions" in javascript + assert "orchestration-field-options" in javascript + assert 'id="orchestration-field-options"' in template + assert 'id="orchestration-webhook-private-policy"' in template + + +def test_orchestration_ui_uses_shared_json_formatter_and_explicit_builder_toggle(): + javascript = ( + ROOT / "app" / "static" / "js" / "pages" / "orchestrations.js" + ).read_text(encoding="utf-8") + template = ( + ROOT / "app" / "templates" / "pages" / "orchestrations.html" + ).read_text(encoding="utf-8") + + assert 'formatJsonTextarea(' in javascript + assert '"#orchestration-definition-json"' in javascript + assert 'id="orchestration-format-json"' in template + assert 'id="orchestration-back-to-builder"' in template + assert 'orchestrations.actions.builder_view' in template + + +def test_orchestration_ui_supports_deep_links_and_stable_webhook_layout(): + javascript = ( + ROOT / "app" / "static" / "js" / "pages" / "orchestrations.js" + ).read_text(encoding="utf-8") + template = ( + ROOT / "app" / "templates" / "pages" / "orchestrations.html" + ).read_text(encoding="utf-8") + stylesheet = ( + ROOT / "app" / "static" / "css" / "orchestrations.css" + ).read_text(encoding="utf-8") + + assert 'params.get("orchestration_id")' in javascript + assert 'url.searchParams.set("orchestration_id"' in javascript + assert '$(window).on("popstate"' in javascript + assert 'class="orchestration-webhook-grid"' in template + assert 'id="orchestration-webhook-format-headers"' in template + assert '.orchestration-webhook-grid' in stylesheet From 54e1673ca7102042fda01bafef3411bfe2ec6a1c Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Sun, 26 Jul 2026 09:12:42 +0300 Subject: [PATCH 12/34] Fix #24 and #32 --- app/api/openapi/endpoints/profile.py | 5 +- app/api/openapi/endpoints/users.py | 4 +- app/api/schemas/orchestrations.py | 2 + app/modules/db/orchestrations_repo.py | 32 +++ app/static/css/services.css | 24 ++ app/static/i18n/de/orchestrations.json | 4 +- app/static/i18n/de/services.json | 6 +- app/static/i18n/en/orchestrations.json | 4 +- app/static/i18n/en/services.json | 6 +- app/static/i18n/ru/orchestrations.json | 4 +- app/static/i18n/ru/services.json | 6 +- app/static/js/pages/orchestrations.js | 210 ++++++++++++++++-- app/static/js/pages/services/details.js | 66 +++++- app/static/js/pages/teams.js | 50 ++++- app/templates/pages/orchestrations.html | 4 +- app/views/orchestrations_view.py | 4 + app/views/services/details.py | 28 ++- docs/concepts/channels.md | 3 +- docs/integrations/channels.md | 6 +- docs/integrations/index.md | 2 +- docs/integrations/slack.md | 25 ++- docs/integrations/webhook-channels.md | 15 +- docs/usage/profile-and-tokens.md | 2 +- screenshots/global_orchestration.png | Bin 0 -> 266399 bytes .../global_orchestration_editing_rule.png | Bin 0 -> 252314 bytes .../test_orchestration_control_plane_api.py | 45 +++- .../test_orchestration_ui_assets.py | 17 ++ tests/services/test_services.py | 22 ++ 28 files changed, 520 insertions(+), 76 deletions(-) create mode 100644 screenshots/global_orchestration.png create mode 100644 screenshots/global_orchestration_editing_rule.png diff --git a/app/api/openapi/endpoints/profile.py b/app/api/openapi/endpoints/profile.py index f4628f0..cd0514c 100644 --- a/app/api/openapi/endpoints/profile.py +++ b/app/api/openapi/endpoints/profile.py @@ -95,7 +95,10 @@ "slack_user_id": { "type": "string", "nullable": True, - "description": "Slack user id used for direct notifications.", + "description": ( + "Slack user ID used to attribute interactive Slack " + "ACK/Resolve actions to an IncidentRelay user." + ), "example": "U012ABCDEF", }, "mattermost_user_id": { diff --git a/app/api/openapi/endpoints/users.py b/app/api/openapi/endpoints/users.py index 4fde61c..9d80d40 100644 --- a/app/api/openapi/endpoints/users.py +++ b/app/api/openapi/endpoints/users.py @@ -18,7 +18,7 @@ "email": {"type": "string", "format": "email", "nullable": True, "description": "User email address.", "example": "ivan@example.com"}, "phone": {"type": "string", "nullable": True, "description": "Phone number for voice integrations.", "example": "+77001234567"}, "telegram_user_id": {"type": "string", "nullable": True, "description": "Telegram user ID used for direct notifications.", "example": "123456789"}, - "slack_user_id": {"type": "string", "nullable": True, "description": "Slack user id used for direct notifications.", "example": "U012ABCDEF"}, + "slack_user_id": {"type": "string", "nullable": True, "description": "Slack user ID used to attribute interactive Slack ACK/Resolve actions to an IncidentRelay user.", "example": "U012ABCDEF"}, "mattermost_user_id": { "type": "string", "nullable": True, @@ -53,7 +53,7 @@ "email": {"type": "string", "format": "email", "nullable": True, "description": "User email address.", "example": "ivan@example.com"}, "phone": {"type": "string", "nullable": True, "maxLength": PHONE_MAX_LENGTH, "description": "Phone number for voice integrations.", "example": "+77001234567"}, "telegram_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Telegram user ID used for direct notifications.", "example": "123456789"}, - "slack_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Slack user id used for direct notifications.", "example": "U012ABCDEF"}, + "slack_user_id": {"type": "string", "nullable": True, "maxLength": CONTACT_ID_MAX_LENGTH, "description": "Slack user ID used to attribute interactive Slack ACK/Resolve actions to an IncidentRelay user.", "example": "U012ABCDEF"}, "mattermost_user_id": { "type": "string", "nullable": True, diff --git a/app/api/schemas/orchestrations.py b/app/api/schemas/orchestrations.py index 760b3c5..7929766 100644 --- a/app/api/schemas/orchestrations.py +++ b/app/api/schemas/orchestrations.py @@ -30,6 +30,8 @@ def validate_scope(self): class OrchestrationUpdateSchema(ApiModel): name: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = Field(default=None, max_length=8192) + scope: Literal["global", "service"] | None = None + service_id: int | None = Field(default=None, ge=1) class OrchestrationDraftSchema(ApiModel): diff --git a/app/modules/db/orchestrations_repo.py b/app/modules/db/orchestrations_repo.py index a84b8dd..b47dd83 100644 --- a/app/modules/db/orchestrations_repo.py +++ b/app/modules/db/orchestrations_repo.py @@ -263,6 +263,10 @@ def update_orchestration( name: Optional[str] = None, description: Optional[str] = None, description_provided: bool = False, + scope: Optional[str] = None, + scope_provided: bool = False, + service_id: Optional[int] = None, + service_provided: bool = False, ) -> EventOrchestration: """Update editable orchestration metadata without changing runtime state.""" with database_proxy.atomic(): @@ -274,6 +278,34 @@ def update_orchestration( orchestration.name = normalized_name if description_provided: orchestration.description = description + + next_scope = scope if scope_provided else orchestration.scope + next_service_id = ( + service_id if service_provided else orchestration.service_id + ) + + if next_scope not in VALID_SCOPES: + raise OrchestrationValidationError( + ["scope must be global or service"] + ) + if next_scope == "global": + if service_provided and service_id is not None: + raise OrchestrationValidationError( + ["global orchestration cannot reference a service"] + ) + next_service_id = None + else: + if next_service_id is None: + raise OrchestrationValidationError( + ["service-scoped orchestration requires service_id"] + ) + if _service_group_id(next_service_id) != int(orchestration.group_id): + raise OrchestrationValidationError( + ["referenced service belongs to another group"] + ) + + orchestration.scope = next_scope + orchestration.service = next_service_id try: orchestration.save() except IntegrityError as exc: diff --git a/app/static/css/services.css b/app/static/css/services.css index 757ecc7..57f9436 100644 --- a/app/static/css/services.css +++ b/app/static/css/services.css @@ -624,3 +624,27 @@ button.impact-path-node { .impact-history-toolbar { justify-content: flex-end; } + +.service-orchestration-list { + display: flex; + flex-direction: column; + gap: 0.4rem; + min-width: 0; +} + +.service-orchestration-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.65rem; + min-width: 0; +} + +.service-orchestration-link { + min-width: 0; + overflow: hidden; + color: var(--link-color, #2563eb); + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/app/static/i18n/de/orchestrations.json b/app/static/i18n/de/orchestrations.json index 42b142d..00e7bb6 100644 --- a/app/static/i18n/de/orchestrations.json +++ b/app/static/i18n/de/orchestrations.json @@ -131,5 +131,7 @@ "orchestrations.delete.message": "Disable and archive this orchestration?", "orchestrations.errors.invalid_json": "Ungültiges JSON", "orchestrations.errors.rules_required": "Die Definition muss ein rules-Array enthalten", - "orchestrations.webhooks.private_network_policy": "Richtlinie für private Netzwerke" + "orchestrations.webhooks.private_network_policy": "Richtlinie für private Netzwerke", + "orchestrations.edit.title": "Ereignis-Orchestrierung bearbeiten", + "orchestrations.edit.help": "Metadaten, Geltungsbereich und Laufzeiteinstellungen ändern." } diff --git a/app/static/i18n/de/services.json b/app/static/i18n/de/services.json index b6948b5..d5b0097 100644 --- a/app/static/i18n/de/services.json +++ b/app/static/i18n/de/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "hart", "services.dependencies.soft": "Weich", "services.dependencies.external": "Außen", - "services.dependencies.informational": "Informativ" + "services.dependencies.informational": "Informativ", + "services.details.event_orchestrations": "Ereignis-Orchestrierungen", + "services.details.orchestration_disabled": "Deaktiviert", + "services.details.orchestration_active": "Aktiv", + "services.details.orchestration_shadow": "Schattenmodus" } diff --git a/app/static/i18n/en/orchestrations.json b/app/static/i18n/en/orchestrations.json index 1e9952b..71ea56c 100644 --- a/app/static/i18n/en/orchestrations.json +++ b/app/static/i18n/en/orchestrations.json @@ -131,5 +131,7 @@ "orchestrations.delete.message": "Disable and archive this orchestration?", "orchestrations.errors.invalid_json": "Invalid JSON", "orchestrations.errors.rules_required": "Definition must contain a rules array", - "orchestrations.webhooks.private_network_policy": "Private network policy" + "orchestrations.webhooks.private_network_policy": "Private network policy", + "orchestrations.edit.title": "Edit event orchestration", + "orchestrations.edit.help": "Update metadata, scope and runtime settings." } diff --git a/app/static/i18n/en/services.json b/app/static/i18n/en/services.json index 6197d49..dbbeae3 100644 --- a/app/static/i18n/en/services.json +++ b/app/static/i18n/en/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "Hard", "services.dependencies.soft": "Soft", "services.dependencies.external": "External", - "services.dependencies.informational": "Informational" + "services.dependencies.informational": "Informational", + "services.details.event_orchestrations": "Event orchestrations", + "services.details.orchestration_disabled": "Disabled", + "services.details.orchestration_active": "Active", + "services.details.orchestration_shadow": "Shadow" } diff --git a/app/static/i18n/ru/orchestrations.json b/app/static/i18n/ru/orchestrations.json index 4728e8d..c4be556 100644 --- a/app/static/i18n/ru/orchestrations.json +++ b/app/static/i18n/ru/orchestrations.json @@ -131,5 +131,7 @@ "orchestrations.delete.message": "Отключить и архивировать эту оркестрацию?", "orchestrations.errors.invalid_json": "Некорректный JSON", "orchestrations.errors.rules_required": "Определение должно содержать массив rules", - "orchestrations.webhooks.private_network_policy": "Политика приватных сетей" + "orchestrations.webhooks.private_network_policy": "Политика приватных сетей", + "orchestrations.edit.title": "Редактирование оркестрации событий", + "orchestrations.edit.help": "Измените метаданные, область применения и настройки выполнения." } diff --git a/app/static/i18n/ru/services.json b/app/static/i18n/ru/services.json index 41ad6bd..440b003 100644 --- a/app/static/i18n/ru/services.json +++ b/app/static/i18n/ru/services.json @@ -127,5 +127,9 @@ "services.dependencies.hard": "Жёсткая", "services.dependencies.soft": "Мягкая", "services.dependencies.external": "Внешняя", - "services.dependencies.informational": "Информационная" + "services.dependencies.informational": "Информационная", + "services.details.event_orchestrations": "Оркестрации событий", + "services.details.orchestration_disabled": "Отключена", + "services.details.orchestration_active": "Активна", + "services.details.orchestration_shadow": "Теневой режим" } diff --git a/app/static/js/pages/orchestrations.js b/app/static/js/pages/orchestrations.js index 901bfb0..baf46d7 100644 --- a/app/static/js/pages/orchestrations.js +++ b/app/static/js/pages/orchestrations.js @@ -7,6 +7,8 @@ let orchestrationRuleIndex = null; let orchestrationVersions = []; let orchestrationExecutions = []; let orchestrationRulesView = "builder"; +let orchestrationEditorId = null; +let orchestrationEditorItem = null; const ORCHESTRATION_CONDITION_OPERATORS = [ "equals", "not_equals", "contains", "not_contains", "starts_with", @@ -148,6 +150,42 @@ function renderOrchestrationSummary() { $("#orchestration-summary-drafts").text(orchestrationItems.filter(function (item) { return !!item.draft; }).length); } +function orchestrationItemCan(item, permission) { + return !!(item && item.permissions && item.permissions[permission]); +} + +function orchestrationLink(id) { + const url = new URL(window.location.href); + url.searchParams.set("orchestration_id", String(id)); + return url.pathname + url.search + url.hash; +} + +function renderOrchestrationActions(item) { + return window.makeActionMenu({ + object: item, + items: [ + { + label: i18n.t("orchestrations.actions.open"), + icon: "fas fa-folder-open", + onClick: function () { openOrchestration(item.id); } + }, + { + label: i18n.t("orchestrations.actions.edit"), + icon: "fas fa-edit", + visible: function () { return orchestrationItemCan(item, "edit"); }, + onClick: function () { openOrchestrationEditModal(item); } + }, + { + label: i18n.t("orchestrations.actions.delete"), + icon: "fas fa-trash", + danger: true, + visible: function () { return orchestrationItemCan(item, "delete"); }, + onClick: function () { deleteOrchestrationItem(item); } + } + ] + }); +} + function renderOrchestrationTable() { const tbody = $("#orchestration-table").empty(); const items = orchestrationFilteredItems(); @@ -158,12 +196,18 @@ function renderOrchestrationTable() { return; } items.forEach(function (item) { + const nameLink = $("
") + .addClass("link-button item-title") + .attr("href", orchestrationLink(item.id)) + .text(item.name) + .on("click", function (event) { + event.preventDefault(); + openOrchestration(item.id); + }); const name = $("
").append( - $("").text(item.name), + $("").append(nameLink), $("
").addClass("row-subtitle").text(item.description || "") ); - const actions = $("
").addClass("table-actions"); - actions.append($("
{{ _('orchestrations.webhooks.name') }}URL{{ _('orchestrations.webhooks.method') }}{{ _('orchestrations.webhooks.retry') }}{{ _('orchestrations.webhooks.status') }}{{ _('orchestrations.table.actions') }}
- +
@@ -83,7 +83,7 @@ - + diff --git a/app/views/orchestrations_view.py b/app/views/orchestrations_view.py index 7f05f65..3253a0c 100644 --- a/app/views/orchestrations_view.py +++ b/app/views/orchestrations_view.py @@ -297,6 +297,10 @@ def update_orchestration(orchestration_id): name=values.get("name"), description=values.get("description"), description_provided="description" in values, + scope=values.get("scope"), + scope_provided="scope" in values, + service_id=values.get("service_id"), + service_provided="service_id" in values, ) except orchestrations_repo.OrchestrationError as exc: return _repo_error_response(exc) diff --git a/app/views/services/details.py b/app/views/services/details.py index 4067341..4467bb8 100644 --- a/app/views/services/details.py +++ b/app/views/services/details.py @@ -5,7 +5,7 @@ from app.views.services.blueprint import services_bp from app.modules.db import maintenance_repo, services_repo -from app.modules.db.models import AlertGroup +from app.modules.db.models import AlertGroup, EventOrchestration from app.services.rbac import require_team_read, current_user from app.services.serializers.services import serialize_utc_datetime, serialize_maintenance_window, serialize_service, \ serialize_service_readiness_state, serialize_service_link, serialize_service_runbook, serialize_service_dependency, \ @@ -197,6 +197,31 @@ def _service_analytics_payload( } +def _service_orchestration_summaries(service): + rows = ( + EventOrchestration.select() + .where( + (EventOrchestration.group == service.group_id) + & (EventOrchestration.scope == "service") + & (EventOrchestration.service == service.id) + & (EventOrchestration.deleted == False) # noqa: E712 + & EventOrchestration.deleted_at.is_null(True) + ) + .order_by(EventOrchestration.name.asc(), EventOrchestration.id.asc()) + ) + return [ + { + "id": row.id, + "name": row.name, + "mode": row.mode, + "enabled": bool(row.enabled), + "compatibility_mode": row.compatibility_mode, + "active_version_id": row.active_version_id, + } + for row in rows + ] + + def _service_details_payload(service, *, days): alert_summary = _service_alert_summary(service.id, days=days) timeline = list_service_events(service.id, limit=50) @@ -237,6 +262,7 @@ def _service_details_payload(service, *, days): current_user(), readiness_state=readiness_state, ), + "event_orchestrations": _service_orchestration_summaries(service), "summary": { "alerts": alert_summary, "maintenance_windows": len(_service_maintenance_windows(service)), diff --git a/docs/concepts/channels.md b/docs/concepts/channels.md index aa2cb10..ea73d49 100644 --- a/docs/concepts/channels.md +++ b/docs/concepts/channels.md @@ -71,10 +71,11 @@ Some channels can support ACK/Resolve actions from the message itself. | Channel | Action support | |---|---| | Mattermost Bot API | ACK/Resolve buttons and message updates | +| Slack Bot API | ACK/Resolve buttons and message updates through HTTP actions or Socket Mode | | Telegram | Inline actions and message updates | | Voice call | DTMF actions if provider supports callbacks | | Email | No interactive actions | -| Slack/Discord/Teams/webhook | Usually one-way notification only | +| Slack incoming webhook, Discord, Teams, generic webhook | One-way notification only | Browser push notifications can also include ACK/Resolve actions, but browser push is profile-level and is not configured as a channel. diff --git a/docs/integrations/channels.md b/docs/integrations/channels.md index 353e422..4e791d8 100644 --- a/docs/integrations/channels.md +++ b/docs/integrations/channels.md @@ -37,7 +37,7 @@ For each channel IncidentRelay checks: | `mattermost` | Webhook URL or Bot API settings | Optional Mattermost user ID for user attribution | `public_base_url` for buttons | | `telegram` | `bot_token`, `chat_id` | Telegram user ID for actions | Optional Telegram proxy | | `email` | Optional `html_template` | Assigned user must have `email` | SMTP settings | -| `slack` | `webhook_url` | None | None | +| `slack` | Webhook mode: `webhook_url`; Bot API: `bot_token`, `channel_id`, and either `signing_secret` or `app_token` | Optional Slack user ID for action attribution | Public HTTPS endpoint for HTTP actions, or Slack worker for Socket Mode | | `discord` | `webhook_url` | None | None | | `teams` | `webhook_url` | None | None | | `webhook` | `webhook_url` | None | None | @@ -86,14 +86,16 @@ Some channels can update an existing notification after ACK or Resolve. | Channel | Supports updates | Notes | |---|---:|---| | Mattermost Bot API | Yes | Requires Bot API mode | +| Slack Bot API | Yes | Requires stored Slack channel/message metadata | | Telegram | Yes | Requires stored Telegram message metadata and polling for actions | | Email | No | New email can be sent for notification events | | Voice call | No | Calls are one-way notifications | -| Slack/Discord/Teams/webhook | Usually no | Incoming webhooks usually create new messages only | +| Slack incoming webhook, Discord, Teams, generic webhook | No | Webhook delivery creates new messages and cannot update the original notification | ## Channel-specific pages - [Mattermost](mattermost.md) +- [Slack](slack.md) - [Telegram](telegram.md) - [Email](email.md) - [Email templates](email-channel-templates.md) diff --git a/docs/integrations/index.md b/docs/integrations/index.md index 98aaed3..ece6301 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -40,7 +40,7 @@ Notification channels deliver alerts after a route has matched an incoming alert | Mattermost | Chat notifications, optional ACK/Resolve buttons, message updates | [Mattermost channel](mattermost.md) | | Telegram | Telegram Bot API notifications, optional inline actions | [Telegram channel](telegram.md) | | Email | Sends email to the assigned user's profile email | [Email channel](email.md) | -| Slack | Sends notifications to a Slack incoming webhook | [Webhook-based channels](webhook-channels.md) | +| Slack | Incoming webhook or Bot API notifications with ACK/Resolve actions and updates | [Slack channel](slack.md) | | Discord | Sends notifications to a Discord webhook | [Webhook-based channels](webhook-channels.md) | | Microsoft Teams | Sends notifications to a Teams webhook | [Webhook-based channels](webhook-channels.md) | | Webhook | Sends notification payloads to a custom HTTP endpoint | [Webhook-based channels](webhook-channels.md) | diff --git a/docs/integrations/slack.md b/docs/integrations/slack.md index fc0c979..23bd31a 100644 --- a/docs/integrations/slack.md +++ b/docs/integrations/slack.md @@ -178,7 +178,7 @@ In IncidentRelay: 7. Attach the channel to the required route. 8. Send a test notification or a real test alert. -For HTTP interactive actions, configure `public_base_url` and ensure Slack can reach `POST /api/integrations/slack/actions` over HTTPS. Socket Mode does not require this endpoint to be publicly reachable. +For HTTP interactive actions, ensure Slack can reach `POST /api/integrations/slack/actions` over public HTTPS. `public_base_url` is recommended for the **Open alert in IncidentRelay** link, but it is not used to validate or process Slack button actions. Socket Mode does not require the action endpoint to be publicly reachable. ## User attribution @@ -244,15 +244,20 @@ Invite the bot to the configured Slack channel. ### Messages are sent but buttons do not work -Check that: +For both transports, check that Bot API mode is selected and **Interactivity & Shortcuts** is enabled in the Slack app. + +For HTTP actions, also check that: -- Bot API mode is selected; -- Interactivity is enabled in the Slack app; -- the Request URL is correct; -- `public_base_url` is correct; +- the Request URL points to `/api/integrations/slack/actions`; - the IncidentRelay endpoint is publicly reachable over HTTPS; - the signing secret matches the Slack app; -- reverse proxies preserve the request body and Slack signature headers. +- reverse proxies preserve the raw request body and Slack signature headers. + +For Socket Mode, also check that: + +- Socket Mode is enabled; +- the app-level token starts with `xapp-` and has `connections:write`; +- the Slack worker is running and has outbound HTTPS/WebSocket access. ### Slack action is rejected as stale @@ -279,9 +284,9 @@ Incoming webhook deliveries cannot be updated. ## Security notes -- Keep the bot token and signing secret private. -- Do not include either value in logs, screenshots or support requests. -- Rotate the bot token and signing secret if they are exposed. +- Keep the bot token, app-level token and signing secret private. +- Do not include these values in logs, screenshots or support requests. +- Rotate any exposed bot token, app-level token or signing secret. - Expose only the required IncidentRelay HTTPS endpoints. - Keep server time synchronized so timestamp validation works correctly. diff --git a/docs/integrations/webhook-channels.md b/docs/integrations/webhook-channels.md index bc9f4c1..9a39084 100644 --- a/docs/integrations/webhook-channels.md +++ b/docs/integrations/webhook-channels.md @@ -1,6 +1,6 @@ --- title: Webhook-Based Notification Channels -description: Slack, Discord, Microsoft Teams and generic webhook channels. +description: Discord, Microsoft Teams and generic webhook channels. --- # Webhook-based notification channels @@ -10,7 +10,6 @@ Webhook-based notification channels send outgoing HTTP requests to external serv This page covers: ```text -slack discord teams webhook @@ -18,17 +17,7 @@ webhook Do not confuse outgoing webhook channels with the incoming [Generic webhook integration](generic-webhook.md). -## Slack - -Slack channel uses an incoming webhook URL. - -Typical config: - -```json -{ - "webhook_url": "https://hooks.slack.com/services/..." -} -``` +Slack also has an incoming-webhook delivery mode, but Slack configuration, Bot API actions and Socket Mode are documented separately: [Slack channel](slack.md). ## Discord diff --git a/docs/usage/profile-and-tokens.md b/docs/usage/profile-and-tokens.md index 1aefcd1..5af1657 100644 --- a/docs/usage/profile-and-tokens.md +++ b/docs/usage/profile-and-tokens.md @@ -23,7 +23,7 @@ Fill contact fields used by notification channels. | Phone | Voice call channel | | Mattermost user ID | Mattermost action attribution | | Telegram user ID | Telegram actions | -| Slack user ID | Future or external Slack workflows | +| Slack user ID | Attribution of Slack ACK/Resolve actions; also used by Slack usergroup admin sync | Email and voice call channels send to the assigned user's profile contact data, not to channel-level recipient lists. diff --git a/screenshots/global_orchestration.png b/screenshots/global_orchestration.png new file mode 100644 index 0000000000000000000000000000000000000000..c71e8695feff20fabf03a2d2e1fe11a2005d4171 GIT binary patch literal 266399 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�U& z+IKUvJf7IvO%9*-yb;W^Kmy8_Ih};XMBX3xV3{ph!(`o4ShQ}2W+h`$PA~XKMR;neAQxGK6D}PwS$#4B^dG4v^vdrVS>JnhT+OxxBpaL_aEGpj~}lf z-@3mb-;(tc4KPj|#}W*`tBZe6owM{&+cTS_lJBQeA7cAb1|e|IaLAr_3U-2kQzMW1 zy+o(QcTz1gaUt~!3Vh4g{9FrCoynZYEAMbHiFd&c5p9E?&)2j?m!F!zbidWXM(JNkYwsjyhJ65`Al*fukj zJKZ(5Vg|HcmqPuD&e^cFVg<9GXOLai7?a*`clw;bpQ3?`rc_c?mND(~Eo|&d1#@FJnRw%!1 z1m3;JGZN3?wg-B&w4;a4VCgywUh3VvMF0o1=1v{A6hPd@-Op!SPO!4hT5Go&GfhG> z22SWr(GzWT0c=hf+xxmRhi4#JbFmVd#g9Ve59^M^_kqF)p%mc=ijEM&F;XNO>sL

aUVW^qGL_#Ad>0mo)jTKmOYX*s0)3YjwLGKqdm$ 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 Hl+XkKXrM?< literal 0 HcmV?d00001 diff --git a/screenshots/global_orchestration_editing_rule.png b/screenshots/global_orchestration_editing_rule.png new file mode 100644 index 0000000000000000000000000000000000000000..285f3815f15f3b5845512a59e839f85e8a867c23 GIT binary patch literal 252314 zcmaHSby$;c`@R7pAl*{ZDU)sxY3Y&%X=wow1_KlXq`PC%-JO%}kS?jwHO8n>`|+vw z^Zx$$zQ^$Z#|Aw2eP8E!)%jctQCF46!FrDM;K2hNMFknn2M;he9y~z*^7P65J3R7J z(EA?`-8ALjJSZQf+P!~)W+SB{_25BO6!wi7`u%Inj|%#34<6ul{qgm%&$-a@!2?vD zqKuTbr^&&sd@QFhem-WWShkGUFZw#WBGD-dJ7FHTG6~=uXZ-Ek2gywsK1C}^ZUKZJ z&AVf0`cOzo)HapMB;WsiwRSVGr{>%K-&d2GbHDxn{cGD)wI6~T{Qvg?d?PI5lqdh| z23tfsIKcmL0~G}3X;AC`+9MLpOWNdrof7n@54DZDK#Av6|Bs{Erg~G||Jeqls#OpIh(J_F0J0V5CREzf>#E_n9Sp z+pDIxJ&GyMRa|RI`_0_smiAux{Wfg~+*G#%!uT1&uvHHQkY(PpcdH*=vUI}|GdYI+_`^D!6J`+-#$-T_`=6V#? zcJcS3cerhI75Do>y04aCkhQ~A>6_*Bg)C@#s_3uY$=5K~>x#9yRT;WSDceI;8%KbR zE|p)VwM5-)4g~%~&5rt)DWa8=l+%61E=frmS~g78~quJG}AHT zOoN@|yNoak1b}Sa{Hy=IS9z3=*Izdo0cZW?ERfjSAxvpd32UJ!SxC7FxLcRg)5ujM zcyU#SdY5Uufyx(_BAE@dp$^MW#{b>eqdXWG%&<+M({bQ3A5VjoU^~j}^)KOX2;0ve z3P^Brq=Ze+|w1U_8^I>IOJ=VuEde}nJ$ zwFp}ySn>E{gHZyT|E2omM5$QBuCCgbPkUIvZj=~BsX}5zo1$=%c*xjKHbr1 zU~Tw_+dz$HSnH?G{KbvPq(yWygbIZhvQoe{apT;GT~g+G@6uSRIO-@j%7q3>@`YDsM24WAJSNsa|$ zoZZ6HLacCt{ic7OLG-FlM?0$c1V$t~yl|iLfe*w@lY>or0-LyCu^!!4+*GqRwKDYxuoF1aHvGuRnJG;)TQpT<;0Men5+Ky3LwhozE)NNNolwU}~lCf8h+p{sAD4`1@q{HeZjx!81D)KdqFlrx;X*X2zG96(w6_A->9zqj7rvGn;aZZh zq}fgX*!nF?F4RAYU{FGEdiJ-?PBFcocIxGDXYiE-e!UE&j{_(S`zOmKf$6hj_>{W)F@6V}Q{NSkmpsqVzH!<_`*@7DF~O(ZtTXvEw* zh@oEXj?4hS-btX7#oMOhB{gK;JjwCuQPLi+$L-(n-GjJAGBcJ1k0GlnaM4kYV<5VO zN_!Bnq&m~W+uqbdJmVc!I6V8^t(gS2@{wJa9c`5RC3Var{OOkuPusj0Rl$8Tv`bO} zZ|EE3HUIV+Nc!Ji<9qDWrAUipdeYXdP15R?#fr?61?gwQG+u|moVN@i-NGg7Ym*4z zR+m5nQlW}jO*0<0II(SEYzj+XWNG&7IydDE8yl%>+A-FzEEYNs648A__+L90bX5hL z?lz?cT#lEHO`RVYLuP+|VA~IeZ}yRZq};eqJb**qe{c zyL4%xfp*PM5*kH2n7s)??pW(;=MJQ^dOYJn{yQ)M@==zK_qb}I zLR7?WV$d#00l^uCAZKW^(>b0qp^;nB5;K;g9SMqp@f$Eta*Ijt8O^-Cd8Y-aJcX zZa2lx^on}nK%KfphW{?WuKdw-v$+%`F*i4~yT)2gyk;v-Y}n|klxR&@l9y1}=|2cW z-v$0r6l~#1RVXI{T=<_=STj2tjCvlUvUcM4$v^j_ke=}GzYuh`&8P~omRpA4TEN{3 z;)o3Klea#P8jfl$5SsL>lJsw|WGni@zyB^-osxrMt!cvkJPTz0w<>1Rw)j z`vN9G;ZQT=IACop1vU2OE>G-Q@@W^!D}CAUa+Y!47hS=EXvJi?Gzx`fG1o#2lYrkX>A=-TNBqL3Y)^#S#_8)T+D>aYCaWl_Klc zGV;pu@qZJjBJZ!vb~Jz6C%8dPbZaTS%+2Y{&h9WCH%lAmL9}}o<`-=Alud5Eb%URw zk8@f4R$8y`1M8`dotmH+3pHhB?d+WsA|K?BYjrlBWJaFf{i@-=E3q4iPUgg2(WlFu zvCC{egTgcEl3*!P6B3&X`$Uha@RFv2KiEJ!;s2QmFq;ja80>pR2zFJs?V)8PqpwzboZ z-Qd>Jf=UH86jO4=jtY~o;^=Io^W zXQ}yj7@j{KMrg8ZdrX=1TDuYyqFiB0vcA(~6_Q0X(8V&$HXrDZAzpM%*igfnm3V5Siw?10NTkGaMWyoe!gZ11eE%HvKDf$GK#Rm*mxO@3R0en?)%W8- zAePs-o1%{uIU%}*w@~i9V`ExwH4)H!4W#z#P9Huc-MHQ;DD|G_J$LN)1#Y=pU z$C7VdJe@n5I%r9)Ls&HHmJ6%WYnID>HHg+%MPBckXK;c$cI_#+&t7?y2z;H^`6nsS zX8&C+lU`^(>Qve|L~q?#^`Jd z{oK0wAlKhf=7HwVh`Nt_%>8<zsKv0h z^99v17&2m|3Ab-rn)O4>Eu2zmyQ5Uol@z85gtOe9b_oUZ{iI6`5Hg*Xnx%7YU?_hm z)F*tls%CoyQZ0FuFcVscuH%sobKt5a|GRfWZvNHsZbf~mVBxF;VGx*n5!$fAN?Bu) zudGp%m`F?AHQOyk$%f{h9-{|iExpJ`p2>TLkVIYr7Ns54OhYWAzVXP&za1r3-1_kW zM3Wq?#+R74w;wZ6@?Xt5;*5AKM}rBb9VB+W;Px2{Z?Q0YY~~U)%7&bM5I5p>;KS_4 z=Med(PGZMYKU3THmy6L|<*PiF5PLn%m?u1eaZ+zf9aIp!ml`2m-q^zo?;6?Y%u>M8 zGyYO>VVwv2TMnEU$m^WmnKZ9ECD2fprD{hRp7w`+7m>bz`p ziw+<-;Z5wz;13WndbPlCwZ-)O>A)y%{P|p1#s`9TyJZfLDD4IHPrxke&nxG`?ar;1 zc;jK9l_ZvZ0zmLbh1O9HOMLy*ntxr^wmtGj6%8DwrupSp-*^N!`8!@Gb2FSp$x8}j zl*XgyD~^T8)h@^CCKrX9v|!@IiC^mxcx))!mvlglvb>SGNJnNfB7K|pbJ!{fXMEiZ zzF{*b9C#lv+w3kLiH1=BzK=Pw;;9WOwB6T9$PAQc?~CSFVKkzYNs@SpU!A^F{U0y> zOQt|zU^+AFCwzL^${hmWXVsgFIqsFM4wKK*?{u2RrtT~t(*o1B_${LfTs9TZt4~6y zurv4Qa0cb8u~`aeCfPFt#fS?2Wvws6O?X&Tv(3~HFMrS(!D6tLbl7W3v(=Y@VvYMb zt&Yib=we%1p{IB^T(n7d`Z1-$p6`)!qi5M-rI}YI{**Y2s<*i~d`x$>cs=Gt*|v8t zJ*yO?DGM-J#|>vvcw3hyft1^VSaAWW=VOi&lc)T$8T%H|c;1ct>I`q6s72>k{asri z+-}k+Pa4`*4wx+-SH_NoD`ie!QuD`@` zdb_5!SQrbguw}@U{h>?T*r8%R*a2gm%fCN<+hAE&tN|6bq);x7kW`IrLMhh^ty{$$ z_!^ZA1Do8tL|$`8S-V_Kh|r8yICA<4I1BFPXfVnU9>e605VV?&)A zwmrf=U%Ob)1D>3e^k4lGF?@skoTZo5oWidMTJKB7JC1G-EDhY|%h8~ffG-Ctc5_3x z-dS&gTk9Y8mB?p3io@1PKUrh&!L53|7M&#TWtjx(7CJfx)LJVTu$kEeUp~(w*58GS z%5-d7di)?U%{8{%lAQdn!i_ANR=RBqjU-5xZfx^}E$a8x`5gqeaR0dp3uievTL>mQqd%BppMtTeWM}%Nq$gyt*_xpjfSwT0d@vB;nrhsO90VrM|08dh4nykb zV1f=0E^(~>kb+D$$b${Xy4LTy4QNq=O(^^AYqY3}%t0K#3Sz&JM;U+|-c}ATcQtvo z$IuhEvz#cnsZS-U#hSP)u!wdq-~74c2-%6C;06gL=DU?_`G#8C)pbbnVEj(Ki}U?~ zd>Lv_at=rljlGg?jqmyViGD2Xd_MbFq&?eMv2Jj+m$2lsG$%^w5+$?h77p-!7?768 zf8{5!jYl;CdvQLbM*X+*PTm+hEzO#KqRcr_!CDx~eiWSHk4C2Do8t6r?t#s8G#~s+ z|0=QIl%+FcvmtRsrFQO{Xr4=T?x{HC$Da_{M(bXmI9%?F?a$IW2VV70C5n}W($hv= zoeB4ze}VT9yMA`GNZ&1iiq}4I>C+#l zYR_z}8d8+6jog7hkDlUI(dS%dutQW`k!f86jewdRt@LMu2B6d)yvyl(aL!J#Tfp0b zXC2P$|IsGEoks$GHndQGqQx<(#VPI6wE5lviK}g6*)>(`xDjqp`X;y*C%E}qQeG70 z-pY#nrU3Apn7LWY`JSFth>$citmYf_WY?U$c{5|7e_vpGE_}+3R{r(d3^TfSLdqwG zmg7@qSi49V!npp|zepW72yFbm$rf#~88LRn`qN)PG-DOj#5GIW2?8H=RKxFz-W3do zJTNbdl=)aGd6*i-(|OwXGolqN;VOXq9;*J@`AP69I9r!$D~k^E%LwH=_6mh94c45S zl3lXTI1|u+)YUuN_NzpQ05S3Ujw;!dUrm;y`IggK9=8%Ii$tSHr~ zRgs&>JL=x3sjYmMD!^QSezTS)C!34iD*Ap8PbR0op?KzJBfT65sMv;}y5zH(-9JcZ zWbkXfQMELxbX`R$D03}?ma*u!wz$@LF~A)#taZT8nLzQ$kg7?$ikkS9AwF5m+C ze@YTW4(QPBKn-rC#*+4}jUrd26}Fy4oMYP_6d-A-Uy|7HcJ_2{{4eH=P@xchx3Cot zjQS@1(XCPq()@y^adR@G>e)Jldv1a8N)wv7mDOrvcF6+-bK$__t=c|rn!bS1s6J0` zv7H;{Z9(znmF3>+>qSp*zk*|wCnd}oR(wqhbB0E2UR-!YAkOdZ)q5H!+%uqdr5EZF z4)r{qr?pVmFXN&gH!q>oSXwZ=Y5Kb38sLe!P*8L3>4X^eFJ6(1(~=~;gbP?$2pE|< zuba(O?jMo!_3CW#G1;aPk>Wv>IKwMk(ucpA$}D?cvU?CNMZ}@Fz!EU<#u*&$`sg2z zYz_DO`F)HMPL`pbFAwr{c0Hja7PdFIw9%Etb20KFG$old?RQgX)<7vXvXB7@UUdwg z#N)xRpNK2O%tN%Iw>f}g0@`F_=e`|z4BZzvr5ygR%N$cH=*rnt8L5Xo#Mi{4_9IjG z&*avd&Uy*ey%R6Gk=xeqLAu`&x?@xRk-k>JJ;J~`~ zcKXOSd}W}Gz`f_%cW5aJ&TCY3dntWp>zlfAfelQnN*mGV#TD|`qdkM{=;WIw_gIZ- z#oT-LbG=t^D{18V+7CR#Vzyk$i>2pBrrFA*^SdvD1hX?|rABf9WwfsL2_f<01?SK5_c^$}#k`=86dQfa+E$=$PH-y7z= zM?HUZaWT4Ql~Rs1r%9E5OWs=(3sl@ra}WV>YZ*wq)6(4M{qMHM9Q(CPzY1-AI-k7h zKPC^r!eZ9^8kw%m_4b+`vY&qox*AI!onxx6L3tpTd@redEmH0m`SU``tTO<_Xvdn? zyNJv0LJYyfXYtuofHsn3*Y0)d{j0uN^Z{OW^JzxMaivu5!+c~Ks7DDcoxIZg800$n zY}+Q??}TC*doji@O~OR;?OSB26`O!)4IE=MuFP3|8rJ{#bRofF*e zRDw*;8WPKYMQc0b3`ON z6eV(NGy}o_i;h}8-w|fV7nlGAQVhVTm`1bQl(*Vup=M7`RIUgRBsqb*&w>D}XYc&P z1M_W z^*sWp*qZANXWs9Y$z3y-i$B|>7)Ey?lvE})y%hF)V4S&m7gPwm<=7=1`V^tPk^4bx z=5^hO&IkZ&w0@UjX>1(i=`!jk^^7(x`4u3dCa%KI^-BS0EoaNS8JfC?*Yb=*2 zO{``D_1cnvdnG>j0T8&os%u*vutr02;wC-87-$B2vQt_PZ~NMA`2OWw+-QdM0{l91 z&!@}a*}b-+Rg?PO-WpbAiNh^iie2ecC{QMDkxm!6V^mLvqwXAnPzzY|$sPf^#eYOb zYPbV4`#i!wV<7}vZq0rDFw-FY4y1n5o(SZKt8P8+NX2_zqiSJ$-=MPxO7~9`A8jQS zVR}bxUq%l4J~7on@%!buf4))PH->Ke(K%0wlwb@>%VI}#;8nrbhzUHzBX_fGN7`l4 zJaigQUK^0^)6@0dqTNY=b+*UeZkIF}jF$M0K)M7^h1*#_op@N@v8^ub*I68EJ`3HT zp0#t)qw2BhiVS&dqI*QcRVJ_M?ac`#Esi)s^UJ9vEuWgrnzY@T$!mwB zMA-dpUS2LlfNFHlq&!k zdtmFH=ZQ`Pj&)T|R=lI!A14!NfC!W2=!5~F=2a5r5o>;Y>^hh5!(TfDi$8?KghyZj zCy|@wAH^y5iSWWUBFSNUbCoZICuyX{EG$e(j*Kf!>EiE!Ufwg zmRS8!@#He^5i%gq)OE=RmL>n)ChrFWO4^TB-#lg5YV+j7o@Q`>>#>|-*XVtt>Uo&u zOJZoa zFYn&`!Rsxo#h6z2556R6cY|A{NRt^Pi@hd@yP-nGJIWjM*~@g~BH`lN^-99mKEwB1 zZHCKPs4Jrs+e@}ppLWf9RzJ>OlXdRZ-H`ma4asB4F4}Q`$ac)*j_tSckkM8{lWoEJ z6^^sph%;M>B;DRVrcU-jf4eShY2f^qwD`lZUSAlv)Q751=!G+|il-Lzjt;a&FuQGT zMGFnTQq*k}WGfBDl3Dan8%ELSx+XJJ4e_j6m7aTs*u1@SU}FS~#9b3vk;@6RcPn+) zc(M=erYz7c?JZ*I;_kP?^RgaK%`@1W6tH`bv{ReNTwmEM^r(e$KXHoTIJA(U%%Rur z?aodVu@0brMJd3GN=lhA>%E_d_$#;G8D3%pUK(w#7$ZV5T<5OAr&(&OvGqGl2PfY= ze-J+If55ovI1pC^#!mly_Yr#RqMYLeN{sbDKc)^6^BxlA7?^2u{^@~r)b?#fnV3(N z)*rhZ$d7WjcH4jc+&d=xrfL4I#F})c?V*cOfT@k^Lf3>>44;jeuVM0r&6|*$b z3X)@~nwK||_Bq#gn}r}LTLs=p2EVHJD)hkvc=+fMWmsze-UYd*Bz$SR6z$b@?DwX1 zhB=$o)2R&tHj+lpQ<4iy$m$AHOnqasxAZn{m5zHJ7vZWl&Cw?%Z|mxJ4)LJYdfQkP zfCLj4FQlAysod|xy&-Yj8B`Y-jZm-)h)47wszC(_QJ}W(CBOF zZI{TpAwpeRO&4TAiO?Rv>TEX76+@lrk{GK0Guh%tjCvIV);d8EZwO9lFd{l|cu1ow zb7{dAs!Qt9xC6mc%NVLDMif6l$?lvZ1LT>-XFx2$1%>*Xcq^;|2L2%`Qr)xeN+t^m zmRp@8pdgB#$JyG#jKAW%aV{*k*u!u-ztxVx1Xt??v;LOU69C|2nRe^-B+zLxb<2r> zSvPqA?mhN~%U{teMa7j8!E$^i%qQ95^x223242Utvf$NYHtMDU4m)c|Is5HyqDF^L zIJ|2!vb}mO84;A*@1W`VsANCSz;pT`KBMg2CLP5Sc-odN6fS!l$D^`)P~WPG_tBAL6^2c*tkf2 z|FC;yq|OB?o-4HiVgh)ysl87!I#AsP&!~CBFNAQb8&!Z&aL>8kR`6VOFeL&PSma_a zZQ>B~ny+skpRppb6JtJ^sOqV=leK#H4>2u^-{y<;%EQ;}O#lwN!=El(R$n^&tG`Y2 z0L`4Lq?agop{@NE;aBL{1=V9b4C}W#G&zbfm|QyH+SR#pF$@lAP^_W@on6Qyj+9|Psd;wmwa4IuwBiiDpoAaQmGYY>I8YA?s_oZ4o?|}-)_bWu zPAK|Z51Od6kOkj)St5f!K;%O+h~>`8OBSN#S#UTz<&;GSV?IQbX?{igwwzdN?Y>|e z0!qY$t!g6((>4UQ)K>T1KD5UPmH4I#wa;uC8deAyxi(4N(UP}Q4oK8*B)OsFwn-bb z@CaOQN`V<4Ropc5ju{bKsh(k%)NyfG^@n6dVkR6Yn|G2fe?l9*sB`U~w^=e1DlJ*= zHhDXFX+?SR`YnnjNi6=!OJ89pC$6vLePM|FD56*GNppMUTaW6T`JK9HpC}dwD_NBhI5lbw*=_CG7QSfk}nn;6IB1s zi#MW1z~y3r?s>%1`I{Q0<)>ZXboF5t=UPQIHv^$z3`{noTu5&+IU*##npFgk zIhoA3Eer@}l8+?^TsgnzvQ_i$w|ahEBuKq~M!mf1S$HtZW|8zZXMr_o77H`L1mr)$ zk~B+ao)1w4K}c6(eu!mUQ+?#nj@Q>kD@aY6V{G7t^v~umg9HJp)oLt}74FEynLHQ! z_OTM|cdKq%EpiUG1&w}PZefN+V@bP3TR>1$-klN_Lkd@T-XK)daozow;#bFO+3_6q zHGb9GOLM1LfU9KC$2zY)5AIIchNMP={T8JTl?$~sZ^4b180m^dYo~Nm*Bg{t*g}3D zp&>Tr0YWM0zcMya9lgH?Pax}fr@)-8iQ?{g$fygg{2zx7Cen%SP- zV*7zu_ZA3mV_3~Ae6 z$b1R~MNHt0m?dt@+zxWLIai}A6KFi;^?r3%RqFf6Jt0anX+|wNg zc}aq?z}%ITEOk)sFZFgt(GvT_uQWccd1D-=Da<@AF)v^LTjv>pg!t(ti>t0b5fu&e z_+6s{Oy%mk7lI`!hIrMc1_yRotRUSR8t<=eGz;;8PN7bhVu53`gy%5I{*dg~iuo&n zg~5td4-B2;Kipxyi`$-?yHK`J!$EsV;&Of+;ye`MhuE2kt>m6#(J15gM>>CPGA{AN zg-)TqESHE#^Rk5F=|hVpiMg2x zE)$sn50!Z}sgU()e;*3CSCBvl2Ne69#LCU#$Hv;^*=A!a1-^?#5J~Wvm{biDkQYo>E7OMAAOgR-^#@^%@k&Ke;eQbdBx!X4z7q zV?DF#L-e@0hS_UK{_{$lvuUC47J>2HTPi2&N?D;s@wu!(fq!OpZ+x62oO^7DK`f)y zfoB79%IB5=s%$GaV&9r=2`uyl8e8se)GK@X8eS(5N{G^Y;-R45L8-s>$Cf4r53SGN z_}2Zw1S(ydez&E!K2V3DZyw!s{Q#mhocAD>#>HfoEZZjQATj5gr7%RMyVFq(t{5ZX z=3cnWWW@L#c`+0!FUQ1Nq$zBe}q) zTp%m!%%c4{O-0;3Y~b_6CAXlB#hrG$uk>=ZK$iXls19&FS98Tf<*+V_4INOx+MJ3K zsytvR?U6sYjP063pv){Rv8 zc7m$kGlCiP$~O|<#!k}s{PX}kRIC7CSyP^P#x2b{^m~W*K}T*Mt-vcK4fh2$)~;v) zTrmSfhA}o(b?$t$!c{%8r6%Q`Jqx}WdQsgoS)KBOfTGXm{2nD~rbmVZ)|BvK9Ayd9 z3xPB~9*j!oO{T`i7P5_Y+94Gs-pHx&&|;`wMn8X6eO&YCX3WI*Za!Egr$}XS$t-6k zJC?Z*gXne5jwgQL|#lYaPw9(2?9B?veGQo>%DDV9M@N3olXc9$uHf z#jh5Lup+1pyvIF`QJ7EHNu zVif_+M=GbLZR9CVv3(PAJY1h^GAi!rX$MCXc7-ufA`0O4f*Z!a!);tR4SV#edDq7mqZTxYcFZ`J^ftM(%cuCFI5VHoLsXp6#>tx=3K&6 zH=cv-IZc;7)Wub`y*mTqmdNYm>HEallQiY-0IaMy7MP-EDFCsd3GYSacTBHe7{~L+ z``91*KA#qfRP2pOE2b1YHvSOD#AT;S37_{IOL!R@miG6|hzbI49Zc~qN^QGPp!b^% zX(D&ZB`D1b2YvqZ4sJ~<8IV6ueK$1r=zu0uu9Y}892UQURMa^M@1a$6rhm(tD|WFI zIex=NO0+5KQ~;uTlf2opz`GJpHv0mB2+@YwX|`i)C~9GBJJend=#RQN>*Xf!oVQxH z|2!i;s*sOKBKqJPKHrVJ&5dOVWJ!VEM#h;G7;JAjP{&XEOxti9&TdUbR%-h`AKD1* z;k5r!>m*;@tLGt8eZ-|dUd3xT!Qdd~d~SLZ`9d~iwwq+}obR4GkKny|`zo#!HFEmN za<8-~1PPeauLwQ`GVkB&U2<7laMk~`<0F}zt>X5>lB}nTni>~d4NimOiWEVTm&i7+ zR_*X=bIpD1Y+bPIG*$u2Ur9^}74)7|r0jf_7-TzO_{#oSo(^m@!LV?i%kJC=?n?E! z^Nt$ZG{J#+Zl_vCY&Ryfo9#Bk-2;mjr;YWDMOEe}nGi}bQZtIH)42e^T{SCf{PY3~(CoQZnL2=9JyiH}%gc=fMu0-Fgt z)f^V)a2}S`II(J3rUtMH%$7Tb^|Q)Ax6e ztd(EB>rU%(FJiALvT5%dZqy{*4E4#pqP+58hMg$v;P?CBbTLGPHQpf`r%V<}g-^S> zOS-Fre#BF_K)v1^_5Npm&krAUtS;_{w2QG zCB(H2H8H#O7~Ky99^b)ZHSU|3PR^EsviX`{j4=K`gVKDdXT)OB^7r@UK=Uqk0yWMJ z=Q)KK!TqM%*v!D8ZjZ05{qeXpi9M&x=*+&U8N9QBTa$BJ;%^Uc`PoifAB{wPw7I*X z(JYGVWL1Gv!>~TG&gPIQ7BTfiE)p;=rFX6R@>>5iZ~5fJY^QW0VqWbuJ6t+n2dsV~ zTdyWJqb#YgdzPpBWlxh|P)pTRpRP2z`x?r{H%@I#aGRc|1 zM$$<1hsa8eH!L-Z(8dUiN_Ak{2R2TV-MlxEjE{h?MJ&S2(`Hne%_ICWvt6j?J*ajXGLEQpIcE^w$p{*B}Jb!3j1%4x*s=a{JL|0?~~y~?8vD3 zi&$fG$cZ}sG^{-gFOI|Lu1eyC;qM)3<;z!}-cVZ@Msv-~e1vMMxj4qUIGXXVlXL5s z0aj}>JOgVJA6JR-W-!H_zBwBGmwM61GCC5ft2KglNL~r>Go`0s{Z?_Y7G;6H?u$Lb z;hy2t)b*5&JVVs+X_W$pg!H=TQ*6=|`^tl5>K+^J7m|wyjrYB=mQeiR6W7LHJdc}O$w#{`iBRu!65wfFIOJPW^}Otl^)nPp}V1pHGw0u&U4vfWE#%dWzrA* z?fzz?oRkw5IQo@)4@dvY+k^vyZwEs>#*LokMdeeH1LdzouhHADoZKeMb~|4W;-odj z6Azab*HF@5_Ao5mFx$f;?wfCC=pptJd)8;=p$BO}@=)A) z`h+$wz5D1hiJjby+h&}AV6w)1SC>juAk(TSO&~~2#~rZ~re_efIRV?(#IKGV?A!8qSs51k&$ zJ`ep+uCU3u5U{tMJ5|B;PJyA|L*x0E7c-*i@t3lLByadjC04gn9tX~q;2I%~M2G|puHWFYXSN}*uKI0BNZkQe2cbmXVFYqgOA zBj6&~lG7piVK)|=U8qk&$rLIaWMQF-E4#u_Ux%=p3lIbJ->-dL8A8x36=x8~+WE&rr`gq8Ps(*FX4nIlO+>!>bg#%aAYWEg5r~ z%PH$CHuoMk&%US?H-S#f<7cozXH8;_s@20c4MX1d%(P+M8pNruYQU|C+wvYHT6x$U z$EI|948zkEv9v8M(9&EJl_$gJ?&;4J(rr2tmivsy1Q@W#XWd(s{Ua{M{}{K zS=L^x9|b?K13wpX{ym$V?|DVWN+2 zf<+zdTe!ozihZ+4VgYhtiZZ4JrtD5U&h!Ac_f~?Gf$7e-36<5=;zP4e{cZtl_z-2v z6Dx{cE7yDFB_u|0WeqTM>6sekL!8%f=wL~t|0HFQO6WJUm}d0Aoy?)ILrhK2r~pE0 z<9*zS=Nx}D>y9qjw<24+ORmxfHQB?brVr~+B1PLT^rnTPf)uL1($7b~%C!4@u4b7! zJ%yi)963ctmP6g&{T6j_eB6V^QwXek(AvKy*_$;#&?~6#Z6&>AL4~)FenCQc;-Ho3 z&|mX-$R*xhBHH9?T`BET3%~5bqpnZ>5?EGJEj>Z%oKEqQVJb|MBf=JTm>&&Ek81d= z8;jqa1#MJ9vO()W5|c+4gk2XD>bKXbrpz=LhJ}@tKkbF|kS^ynzz6tTV6Ke(TW| zqUnY);^#WCOPP8bKkegt)BllaHj3DKzW2-2$^yn>Z^KZr%E#A5*8UgnOi=@kCwf5R z?mXi;=DD197uenpf<~)L2(f9nsvFvY+mWve8 zS9^3hD#(M{>!!?i>g+Xxd_MZ|v>}IEz`}Xhc-zay$H<;=s80EXlCRo<0-W%yln#0S}pIHdwuUy9#F#z9=4b15&C{{w^YX~(^I+H#2hQx zxgGI#J?0bg0$bdXu8#0xBhy;@#^>@r4NDINmcr!)rr_gKfJeXph4Ej0u|M)GAd>Pn_GpXgECHIE_@rq_B;f6U z>8B&Z$yzBqOD;tLcdo9OUR z#7EFWDJ0Fk>h6j&j{SA|aqy5sz|0ZZ^b`JP^{pm0K@fo=0VjiBuYA6+?D!?RX+yawJ^lSePLdhgICgP^sA0f z8S$L+2QgzGbFgLt%k9C|gX^$XsVg_qeBRk|Dq0g8m)))r25e@j@#Av3({wKW?+{As0>y(!B+Iye@*7wH&Tcyi`P@3Yi@o6WTY?<`p*q~o8 zAxfk)e8t;jUUKlw`5^_6b#tdUYzuI_@Jz9)AQVO^YCA(_bz+Zc@S0*JBhS5Go~v4M z!EJ_6sEWfKf5tiaQHMLN8*Ghr5-`C!OF45H$I@=m+I>qt;t^*({8IBE_tHf3{L&|Y zm;8#WVQTE@zB-}Z$y;Jp?XDtWK6sdsOAEge-t8;y?7>}jOGapfd%n>S+Nk#terQvX zmgQEh?Qt=!MSLp-U@#Y(d9oLcfXm~)i+R^`fd)4rhZw$Yd>MB~xLcl8sC4J6rBKD< zYq8<4W*4VjWPSPU7)^&D2ltx7@fES1_~0tlAr^VQM0kk{6~~X&HT>E-iTseFVaJq& zLoP6c4iL{p_PL%y&EXO~6|^gFgM4!sEd|t;I2qp^Ga$sVpkoC^e)VJhoc`+AQZqIR zXE2T=H>bnq^9{YulYEiu9yP)VqZeA^`ic?b>FveX*JCl*_Yh5|>F2yreQ zQm-p2CnNeQBX;=!Ek~|wk-O}>8c*gmY^5HkXWwi#e?KOe2H1I_`hl zj~C4`USRKz>C9Wkq)tBkw1CqijzBA<&Mr?iMOy0^3fzAyHhghziWSrzns#iyqS@Bn z8^f&NH!yI-Qt7~r;8qTxRL83zk$qnI}!8{Uk!Cd%jkOwI`wf`3?t zo-W16T#Q5A@8ZE`9z6S+6wa}k;_b)pB$iXq-FtDMbq^2HKwi!Npjfe-b=RI{)H@dH6(6NVsZCT zOXTqVxSY3Q%_ix6eF_4wT~9av?1ns#hXl{fi_cT>&C_kI=CURh8)unVHQ>^i7%t`{ zw~AS%H(59E>DVVt9_*19Q3?ExzG}LZVDz(+!J4$8SR&j8=2!RHOF2*y)GTiV5Sz$3 zt<2h~s1tNgz28J&$4+qWnTF-X1~ge$m6b;;CA!Zwn0IA;xnoODeIn`BT=Al=z{Tvj z0|~WmAv7vi>1up$x`w#2`uE!XCpFFfA5Uk&7FW1r>ktA24-zD}ySoQ>cMCKP!3hqH zCRpR{(zrM7?(XjH7ThJ5Gk4DXhP~^l`f9Crl|OM0{4z$n0tMHv4;U%lt3koQaBw}O zuO__GjOw>Zs7}f(s))lK-K6qX)^Qg}uUgMy`*)Z+jKJeOzulxJZq4VsvwM`WWu0`LE z`+D4szpGst8~BCBkM^X(!!SQh07?jr)f(a)Vrhj?tvnl6f#F1ojpD(;8d9*|ia5T*?uruNgX)Ngc)$s8;{PWB~0kwE8`IenE=O1;9v{dKscg8(% z3}f3ZS~g{arti;K%HXY?p0%o(nX33CR1UNh?5+Jcf1BOzC?Yh!)0KGy#K-a5l=w7& ze|V@c6pT>pt>co7mQeA;+x9`MdErj&zj7(TF3sn{q5T(lu8^!vW1spRWv?K_i5J<( zH|5yBr!0as9dg5Dg0M~Z@B_aO)c8`M6CCS-O^*<&BYs?XGq%R&yf5uIh=XMNw~UV!ML)Hgf1T@sU%+l3vwx+zC#9IXhsP>0|AGaiI(S=^ zQ{9pUlG2gtti<{lRQ%G+!?+no=pROuDvZ0xJ2uBND#%2vD}frZ9moWouXmAdIlG=k z+7gNAl5oJ`6zF>$1+|t$Bh*I!`n0IPR&)pE{%G@p$1Q6K8lRb{uNl>7qjSqBw3g#lChamufuFbL%Ly+-M<1Qw&nG%JFP^--*pJCe zKcCnvu{9`xiZ1y>hP9N2a&f`e#*l|sgn7?JI3?JfNiQ?3)y77$b8kUn^yTRLcfa{M zv)$m1(LinlD1Cj0jTme*>Z!tK_R6x7w>&5os?KejKgNQ*39o(~^j2w&Z7yG`z*ofVom zhHqcLmo%cwi6lC%No{M-wo5$m51SFy+4>QS20G)?SGH6MANe>a3^bIoKn)o~ML#M) zDUR&NoOkfv5R!P|cWnw~TfPe`Nqj&0iHM-7J<2rJ!rzk~cJ0*5wHgTbCGT(1e^X+7 zLmytgE0C}x-|QwxXdTiGzZ?;Vz1I8)X7^n$h5Q+)e7@i2Zdbv7g8`ub^zE&NX40;< zFuMN;0q}QSr|@Nm9am^?RRgbSf_KjOnSaVr)BQdt61V&+Lr$y4TFxLER3{;xz50z^2#G&GyGI%#S5av{r} z2f=yKMCu`~4*FL16ja6Wt>K>Y?4%%dfbC&2f4Y?UbGzPldurQ$G&p!oWx20oQ*hiA z^VCWMg6A|oRYX-IqOrtcR|M*geqBEPR8$dgybPM8;RXlO`0^U@Igu`}>U`8!K$uOU zwX$nwy+^af*)W;b$N?q=qWgGw_H{d_oNq+qB7EWbtjMX~ik1<7XI5qNrn2Txl<5VM zsje-_ljaLXbG-M>(Q*NXU*nPFEOeQ6q=Wqvx;fvgeUMVv<&r~}B1)YH*+MEYr@v~< z-BHSrP~ykI?X=VcfN!0Gi8AhEqoYTlS`l;ZUZxf$LG?aqLa;wK z%X6xZz6KYgb6{xlw;!glA;D}G>qjeplyXd$q6t-o9q20(BLU62tDgz_u5^wu0_2C`WwjS z69PD>?7C~3JEf?V-rEL4E6vB81F)^e$obo&;SL0KX>ox z^DmR(z>jA*_2-noYp0@4t6r26@2_lY3Bt*I+l8+s|6Yg{H~p3o}^XLtX6kXzTG3Tlqx(7#)x$L9M=rPE6T&JLZF^>{9sZ z2tlF0Bolxe$LevM;?m9NAA;3Wzui(H@3QUp*F)WCEgwGQXpE>ZX%bQ%E*liFOea?g zJIHFMt)t4%SF=OLCCXtON^MUB6jdrpqr@Yd85yXj3YSM~!kMpL!X-{6_IDFAd^;1H z1X6&^Fv{P@$-U2LCA(PrLw4(bPo0jNN9{hFt`W8N9~bm7zXRXTs%qq65rGhNi;K_JJ{w$B&zqVHhUN zD0L+D;1ZL2ti6;Ivm@*T=i6-#T&?rm#UitxX(y>+z{Yt@$)nFlEHuD0mBqP~9Te@e zN#Y%6s#xJ0_M^tMe3#+NFFl^yunt8NvVnyqG98&;DtN8Ev>m92+o765$2Due;v$8i zA;!?(O7=}bcUNjMux46~&(B?EelH|>@83CcXc>Am>{U>Cq}cB#W?%BZyO-3CcZTXB zZy@4>U{}aTzq`|#IJ$rd$C^sxP)&>(Lr-_^5NH`!=}SyLAlc0Q5OQD0PU%O$*#S2GjI zX0lUWYXg5QZ+`o|e&n1H1BZ0_H^3D}j3-nY8{@t1WNpBY^Ib885!yDR5ad_l0UJuH z^La${a&&s}&Ee%-{=&sY3Qc^%hm7D7!WNesbwtCglaI&W1#?USo(r(`Da_`?R3lNf z&V~n5^PINJM{#A=)C{67o_Ud~(ZRNigFhDCPQK!Ph1;FRx{{9iyAgLn2% zVX)WKT~>kXWu&-%{Pa)!E0RY1Unq-DLB6rvZ*q`VG04$NXU!PQ%^&_)7c&k}INLZn zUWRF0Y&P-A%@^GgY7aanlL2?}G%~9S3aVzYVX9rj6{=*Yk+`1zJr`8)c=}0%*I;~~ zt(7E-C%~o}6yMEiqDHiBa>N3`=GN3CnAF^V=biD8qa@6tN74d{dTwayvENH4f-^BL z7XaEQ9|6a&X^|^jM=EkNez3`ODc<25X*%{lQ5D_7L`CR=W zbD@<*IQPudyQCO$w#^asvF?#~`B(e&?80k?>f{r5J|KVq4c!Nz#68ei9sNHsY$ zR?DqJqp<_`;ilC-Afauin$o-pwgQe%=7gO~#fm$}w5y2@^%1DXNAP&^0fyQr2 z!u0cdmnXsKyU&DgH&(fF)_$#65YWG@B}fpYhg(G|AQc>|C`T8tTZHpACg(`#P+^>*)XND=xBsiEQXiu3 zt(IsapOb4LTK*8^S4%PMyy&EIg*cXW`i&i|Q%)<=lVm#>78r+QGKXfPQzh#^WE#ja z83`nB85)f!zdTB^hQdX$b?>*h&zITL1loSt{wXx|DC+L`eZmePK*pX=Z&V&P&b`<5 z>$13_{zw#HE6wNhy_7GAAiyuPL8&ofwv9YL94_pRRAD|beIK*lorsfm{t1? zCG8sfx(-r+nx(1?9Fe1@kaAvU2C_sm7ZH{))AHRp#Sg{H{RFBz-7xIRw^wCL1Urxm zJV&Pl=kyraw@n2pg<`84?#DE0+3=>=59%?UoRhulOoDA>=Vuu_`^6czga>D$9qIS| z@6MFH0gdOangvKzmCJTYSv4x+aLbky3q5NLOxrlUp@l`7rE6c+*95BAtP- zeQ*wZ!9RxI!ldItEcdytsc7kEKE}PC0CGVP=DGq{BX23TYU|{`k zG9RSl#IH_zQnng}zS06VnD2Dq#hPUL?P5GTON@Y-TIRCHg5H7RBXo8UB0UVl8Cez= z!Jr85NzvDt6s2W&)%rWG*%^ac1MJ>f8%APwWiqH){|Q!7)O44<%Y1s)XPrueH@W}P zLB3H>_+r1MQxRX%well!;CjP620}=z__~ID|U``QCgT? zx@+2ecAX6JBo)}ZrRdjoaEofFL01h@^|Qt=@t>(Rpx|2ovDi~cJrilJZYlD4q8H-_-H z8y~*8hNO;bmXrW)L<@r0>a{3r)ioRvKOO_ykkSGw9(WTz{oHF*N`}vh+UXXUto__f zdjg&@FrNPVCBv^E8dPFkW#h4l3c8~)Gg^$M@vYZ-Gd=5hvnhLys~cG1B{mRy)6Cnc z6j2v|ub68UwN3}79}K)_5DA;wCXVS>;-3ekg}7h;evjN5trBoj&`DGLL(ziLMVhl$ECW=^Q(JEslC+-L?*x+M#!m%$5{`Zg&nOD#G*pg7 zJdI9KX(HxH<(SBV=xlb_W^;M1t;}AXFLa6E7fJ>7e;x7Ok%ayI`0u-BHSf<+>HYj* zj{!K%^uVz8{puFSCXW*J)RwnV0IC)jkqYrqE_A?Ysj!uK4#-yd+Z*E^)PFHuC-U#kBiP|3#wPI*O1Mcc2`c4Cpqv-?BTe~l z{O_A!4fu1nuDtfWL@c>`M$+XPY&)%abSYaM8+6pXq1&PGN@*)<{ooQ3T69 zDg5LI{j55O)72?*jxk<);^}}BX-L1racJybp;G;%a@lQxczxa!zn!{i+aA#^kb7a* zcLD{lh6~dR9DyLE|0L~W=w$R%HdC?pj)mfol@|)dh%IGo2E*pc4#&)11XHVAf@e6a zke)*iFCN=SobVq03TeQcn`_*a+zQFdv~;rwk(KG7&1zaFdMB5oeMn$tKLPg<4m@YU zScfrD*tLgwC1({iwzLNfvib4{a)zSp2<+-~jO;CKdI+?>4ZP(WU%u+{$6XhTcav1H zjAV-#5q*q&wj~!=3Izz|qAyI?u;MH>gdg-LHsqp(G;7enpRfR|8DeZ#i_lD8BN0;R zpNi4?NuB^cF)43IneAjO%-j^3ITqn2eorh*qtdtT`|}O}weKSic&1Cv(pxYc->H35 z=YRF8`#$6g;%D)GumoHgWlfv7`sF^@BY72IUa;(`{;bpu@x?!{yokP=bH&Q2g&mn> z`^K5jDoZR1Q7#Tvek#ZHXOo zn;RePz`>nJUf!L0vz~wBFdh>M98X|bt};p37$4uNnQZ$VZe9WGvq1~s6SoY&$LL=& zU@xDi>>uFr++0TSE!W~1S$khdzlS{k8@NRU#TWhy6m)~xW^;siaa0KeJ*wUh@QLZ9 z^+aG9WcCMMM_b1iB&EZ1h7fWUuZa0qwzT?y#!^dg^2gHm7!v8A8GBqU*wnK#;iiRO>)?#VU zj5H&u_w8|2Ug)_-p!fJ#S4~<}Qt*jiLq-?g#)$D^nP}eTLo`BvoRdwjE|wvbRLNXf z&0yP=QO{J3PXlXjYojVTvh|^ZP>Uo21Kb&8-;boj^j7h*Y!*jp*@lZ3*#kK6je$P9tfjInhKj4mTm)>)~8SI z?}Di-MghTRh9=ndP1p|l_s1YwV8z)ATA~xD4>*K_k1xZxF0-+ZoMn=}XDbxINLB|P zF;+jD39iKtv}5-FIq9xWj@cJf#>>be$T>)t#Fe80Fi_QE1t4e=;jquh3dDU>E^(>< zgzHQGG4VSWaS(&kXbU68VW%437;=8H{MPBh(RZ_ABsN&wp-N*%{=Dlgr_JtuZd#h9 zPUQizA2QH~(lFJ}?tCRYvw!QfCbj5BFn3I-e)^~_3`c_CG6D)6-a|b;j!XaIWIG1n zjC!Tl7M7^W`G1WS_}VkS3ClguWK{VMy)|arHIbcXIB$!mJ$M<&rg8t90@<&~v@F?g zDl$4;6Or67fG)E>6`hpMSE0rFyw_kos=>_QS|(NG_HqMt0wrCLdg4#hE#sbcxrupn zD~P+bnZ>o@@j-u0Jpe`fA<4Wj9R!ddx}{ode9>?=y5dNz^5C^cNAueFb59E1NCzKy zw*}P|&a%WOl>lxj>qshaXzo$!c@qcvsb|q`zQNLe@5&LgKUZC5s67=$fTHvZA2P>HhW5W@f@rmEUjR^}Mz470I|7!+Xl!-bdo+_vch0TUN z_Wzvm2!9Yl{Y(vMA5^KAR%>F4uQ?b6&s79N&_My_GPQfvFnwn&f(S*Yd0eGXgNzTO z%HkKRWfNL`9Erl2n;fx{^xY^0MwrXSXR9yl_764G^aZ2aB_1Y5PEH@%SP?>WQ(pBr zsL#z*epw^8I(6MjEl}KU2V>Iqsj1I#S_`Kr!rwko(xCD#^&y^XJ2N?2M?=j{zyJJj zM(~4JV2tR>14CDU*E%M-Ayb@Q8re48kP`jUT(D8k`KVOB+Jg?#<-rX>9 zTZ^QJ(W`eZm04|Xbr_k%^$7i9EhkiwV&u?{I_?8l!deGzqF2vEh(1fSek^6A=NG?K zJ?P=pdgyd#=cYsrC)qB}r&aG=hBLJgV41;RIp3sK6S?4H_1Wqfve5Gj62qe4c#TwE zi^koDJz{07BLH4R4O{r5LX5$XrY;pX!X2_J@$D>h$-F;a(T4gF+;bri`Z0pDMg=<# zh@HJ(rQLAN7)advd$l>bKP38EvJsOCVa*qV`b`! z4B_7x1SO?U*P>3dg~=uIiDFI}8vA+Q=cXHjkm=eIQNkNeK4-BW!TkghYna!CrQe9Zy=tqq@klC!R{iF|&iPNFV1jmEDD{_RRB&GXaMLR*|`RES6p zWIJ-S7H!rbqcBb5#>orXZaqiK!_Fr5iZ(-hYIm|^hh=-+1M1CHm}*Yx?w9KKt4eIH z&{As=Tilj6bjy}W@IJn6sO&vqSHT(aKwM52ZQdAv{E zTBs|Ej(M}rGN9)ABWn++0jlO^$UArgg#;sdZno*sCh*>1Ylq`7{PGG7%UkHN0`vCo zS4J4xGOaawQ_Xn(9{gt<8$!ty_z>&hJN%%0qx_9pk$fL#*Vp6m^qdL$1>F3S_Bq`F zPdwbNf!Kh{FAI$+%=KoUt(#K|SD#0^d=OrjoPP-%PAm{#99W~5VD-;<@u3r)#Mi}3 zeayOk`mcGR<)JV{rrHksO;s7wZZ-wJ!5X5xmnq?Ty|b+k5eSc3a)=R9LUU)miYLLIn0w4# z<9Ki{W=FJrVbhk~7+wFg*Oa;5m0uti`aQk!$BWHz1ILH7oo`g$RE7mdjPvwwQR@fE z9Py;(+NwW3L~7@9R>Ulz0<2VQp3IC2vk}3m$i-NWsMf*k|VRvr&z?*pCHEr0J`(j_a}4oNXe({m&$ znxSE{Cl0}hw>M$qFGzcJ6IWR$Kc@d}ffWdgG8mwgYeqBCV-puGWv;7i?>hO==0wBd z!GeBvH16{46he@YuTjhTV~yj}%A9!5?W-sgg}E-Zo2{Rtyv_s;8JNk>Z#(!ACamyQ4x9@SJiggPuwRDH=P@W@pYN4AVfDYs7mU769u)1!HdnqFK zfvc6RpO0AZAruv4cM$@i)P-skn)7ppA2>w9c1MTBV{-2~9IbWcd{~GrsW>A$vx!Tc zhRH3^7w2k0%2R{mn}|=k$X-%Ojc|F2C)@# z>ym0B0EB%lOx$B)j+c&$t0A*1fP#0XPt!}gs2kVpQYhnxzA6FEc=?Nf(6S22hz19K zGyUY`XP|HduHJPz_mGmuAKfgrkN(MH?9#mDD87b&;kBHE3-l_sZE#ocwT^FL zsh6BZyU`C+pSntrkLHY_ovq&|vbk$Z)PYmznRFGFew9j z?sf_Rtiqxhse0dS$@8SAJXM-;@@3+XB=}4>iRCV^4*XP_0gKhm>ehJmHE@pjW7=#C z<1#Jx0bgi?zYRsq=i7|Aw)6?ieP#I``<-xX$&O{-#O7$Up7kgpT^4*DZ>EJDuuf-l zV>`I!#Mc(N>Y00ryT{%FU18t|kkwn+zs2bS8M+)KxJ-vGAiZUJbOyaLJoa;#v*aV_ zQepoWQ;hnEt_(NStoq=F_Yz=&E_FeuE;RB~b6*vpG!>@I7$2Obe|Hm5ed(6xdXdK- zvE{Ve`Da4*iO_UH(E!w~wsPL7lrvbNr*~+)elEl-uh2i24Kf-OUg!IDCC&UONblhyx z9_(J-WsDputTsXv5FeaS4(`TwFD(}=*t(LzP1wbmG(%S6M zE+2unT8U;7IN=Akqv~a{n|n_Pu1F7bf+^+q!h*6Z7dyc&Q5X434#wgGEYla%6WqHQ zHU!N_k?d27?td39+&Li2l!C_o#DNGXN+>fQYB?g*9x*ez-Gx;UsJ3Dq-NPLwm+Fix zSjv2=ijM3tiDa`{;OB6LVw{%wI)g52@?8>yKob?MRF!aqA<;)v+{z})bUm(YJ!Ev2wZeF<3#Tua5-4_K zhJ&{hz9r2y-ubT?=E)L&Q)Q~2e~ij%0b7rM97$7lp9x4KAs%oSFy$bAxBh$XyjQO6 z;Eba8d#>TwYtwS;9aif&gcEoC;=s}pYyCYrLSO*o^BbM(vx{5fn4t2;oBw;d(N3xW zJku~j<`Ov!oS)ors@T>@u-Mo%6-tnME)#ji3%^)uvWE?ek(*WAWYAQ__j$%P2SE|{ zwi*P)Z?KH$_{ftLmWBdhS8_}4t6+8#XsmWTbAz2`PbB)9IgrfjB+H|@TDj(9 ze?JG_DK8=SZY(!zE3OcF`k?hGb>~j+@uoZt9Ytc>o ziT0*A9eD-euI8{RwRpn(HUSGN&1t{3*w_5Ted&`r>L61ROM5_3o$jS?sc+?Av({S! zz1Pq{6uC({Cq{U&T(&nPPvPFP{Pt}hl=_s8Y^F(PI`iLrf{lI=MTuCK+=~jF?wT;c zfv~)Z>bL_6VNWAZ?&-$e@81+5arjz@MtGH-m_5Nkm9PfFD4#&Ak0vmpa8emShBxO6`G;*e% z_wRRcX}z&8RF4<`#eg5MtA$pFnk{fid}XZa{tM_8=~re*%Exe4VqBga{!V7ilSqbE z?9EK4sBj=TjFZF#V|o*2EoDHmswZ0EGDfW`wqEK<8VYn}OiJ3@ON>=0| z`_4HmE3yN?-ZEe^aTJ}Bg!c>h=wp@E{3k6p0tC~XnsA~pntQ7CiGi(uH{bEE$>R{W z!#-WIZ`6~qa7PASjvB=tr4YpKTVFDOSS{;R9WJV&Y3HWa1nIicu=3rmUYqs2|Jf`V z=M^>&;P~wcFA|(XyY&ra$hb;O`{xtEl(7_S3q`3KJi^avE#o*-lbD0=;QguLF{a0Ab)dL#HuqU%AbQ!j#iolHSU+`_>Q<2C`gtCQh zg|%_!rm^1%u7_w^(Rwe4;B$Fo&#}sFp+MX(s}V%4AI0GF3PE98l(?4gz$M-9kF#jg z>?TCIiER9}KCOvjYo**>jX7?`Em*r`Fz^FfC?w!t&&QP!Zs-kk6Wvf;-Vr#{!5$R`X-3jMw=vj}RzCce=0^0> z_Hv;Sd~~sVoBR3jbShEm!=_3Efl^&dgc1&6rTre>pNbLI>$&Ua^aGe6$lGPWs| zyx4aqaR1CLD;3o*XsF9Mf)XkF<;~*NM+cI`>&QfYMdou1Yq31NC(e5v-=ojw9$2?8RWg+CN}si#a-9#F zb93~Y7})d(4uE5-h%$|?nJFFybL53lRePi;xYSpMhvg-)dbtH20Ssnkh2s+~MhnTc zkgbDl0l+_A&ZoRYpj^9)#}uQX9_zrxXwo11Gu*pbZ9emm)pb!SyfbqV zn7n8q(io6C9C7cObvd8P+mFfuw(EBTHX4c4OF1>JY#^=MRgr~oOH zJ$~9Tkz3%^c*fPgtI#&!Y{<5^SU)-DGfxrx7c?C$9f%T+W-!L9;#q*1WtiVPQ<|%) z83=&A*XVS%Sbzkff`HYJ>O6ZxI^ki|>EPkZ{U~w^yk8&2V18>=M&YL9sTM?4<9wUV zQg1tY$@C*;o#;e&8AlT~gRt53R(%Wf;!qtaF&rw0?^HbI51*ka<~#n@V~uR{`-0$# zxRp$qdse7F&8FMavw_uaGHwjC7bLiH2=0i*mFIQO$S+6fh(zcJHz&9nzwGLWrMaAr zz~jcK54nM@=yD#r!GtMPzvkjIHH#iRUm&zAwE1oe-Pe$Q z9PS{?Cqb*Tz2J8I>C3amshaDVzS4Q`ba!J;mH|*4KMEKw>WegUtTl@~O(q(5Z*i>$ z+d|;0TXD&_)0L4Qfn=~gNz%7C_f#ZsXxJH`J6A52KR;~VrXOlcZR`t~rNs_XhCN9- z`For(zG`)oIoDXy_%;6d(=%=6%yO>BMn&QL0zG2gf(rbT{)uli1a%LzBs@<{2#eM5 zQuK^@{n!kK#eA*+%HpAZ57tqhU>oq=Dq6D(-=M^nJz@Lsq zvv{VL)CV0R&f8SPkOjPTKQD*L&+n>+#48D|*)-`-BIjDj$@9Ly4)lp`w_+)EaS=k$U4rQxL zuFofPWKLN+%uUP|yd*{(`F*d(h!D}mP4khrf}#NN1gRVqST8(iFyNA-qaNzqwGtRg zZl!0f+s({D0}M}&!pe;~t1?WOjB+qYk$-DH6daNfS%=-o2tnrya8IPkKc1TIS!My0 zq_#StZ}nB|%EmQXe~wN&)fvT@AK;huY~;o^yfq+acF{K$2&U|ESBz&}a#+<9s0wk$ zkrn%WO3n`DR{IrK_G9|KK>FV>^-j*s0;?Hm^#@UDD2A4*-?SDt^I*`E|0|us*`ZAi z0#QN!Bi|Ad1vh1I(qsI))Vg2PGyGsb$IFfQ{=b12(;+)>YxV$PQpS!T9{aep{i1hY zXNta(a}TNpu@`sE0G^|`?^RL0BFLvFI;pC6)(+a^>IASOp;6C`%Ve_ZQFO0PAOy`v zH(m^6{iLmy{Sq*va4sIx6!=+_wUz|gY}T|cms9+;Zq5EWBb?;#C0o#Q&EQULgxdiq zJPHF>tk#LY`a6FLW;iYhYFG_<%-YaOd8M-?(3#P+_|tJvyy1VEGt0jPSy~KJdtp;ld(mP^upvq(}(io&pv&_+#9|3NOZ0F;-HYB<|l;Pe~fD`dJGX<2Xh#4-tc`2v4 zE1eBc%8BNi6|v+b2j!?*_rln+&IqcxTPE4<)ZU0y%`p{Snh%b3*t>7spB^6Rh`7DA z$#v?;Q>#lFGuZZ{%A*42Quzccj`Bv*fvign|CBqifmyK; zltE*OY>!8mMmO10%Zz^A?ud0$k@Bww@FiFp+kQXb{Qswn-c=&sfTg%kF;D_H!Pt^9=PSVgz`s_`-A9XA z^86^{{MoWN%G4$FTuM3A}AOI&NoGf_;*P>uoEURlg()-rjN)sy|O6vA+$PtYPWdXnL&mo#43 zk*8nQ3>)GC`%I6U1u&d!BTSA^srR0PKrQXaPZ+H%(Ey>Sts%Y)nI%pS?l)`reAhxSGEJ`) zb$X8;oof9Oa(eFNlqk?C0LP)r|1OQO?l~|{axmEueM_Z(+^u;eNq^THPE(WOs7(J; z*C3EM1Qm0#k5$9VSQ5ng6jap@`c9#{sB9dnjHsgn=EaTnxwIp!?Xn9{bbE_78hAem z*1s8{{AC`7hCeO&5*|XhH!`bk_Nrc&bKfMIEI$@O3m`C^rAD9IbIqZybahtjWl9vI zwm0PY-SN3(+e;#mJbCG$u&S(x%|CWHmyw@VUch3)p#x^Hg+AhjJ`jAvO~cX_*%5ny zBf1)oiz~g>3I?i<$N$-*D5U|@b($mMU0${9##--TyQncUB{yW`zzQ6bu4l6oVj2DN zce28bZ)1x5QVmavnB&c78T_{LH9K7IU;Kd@nd zRy&MPCcJ2M}Cd1qliZE44pXf+UQ^ABCs6v}#+|OB&XV z8~M>YI@H9_e&Wng0OCB0cqd++7Rv+6?j z=EQz;c`Vw_v&1o=x)fn{X54{LzlE2GI?q92=S362@%<8%pr1d_YKTqR$;!r9hLonBJPMb;-qqW8aIKN38~UirpJ}( zW0Y~bc%<&2ri3`AnMF>>U9ENjyx{u|a*z4wAZ3epCb^~i zlEa@lOmFtTw8MiGBi|lf&dABA5 zU+)dY;kpUFMTi)37z>fN8C7h1#y@)O&BWeZ3*Z_oSUINe3&emN;nI1298lJPD2~!K zP#>WVt3)j98*Or8)bp1Bumy`7Jw#4t>Lh z_$z7*m8q{ww&p*(%-#C!bT+F70iUusrm!7!jNfp2uO`8J$n5KGRTwjF^~sl!ti;!K zHY(>`x&ssgbJnRo=cGp-A_NowMr@g98JEbdjf!X1ik0_<{pd{EHsqeVC3jlBF-hFB zpt^0bBI=ncl9yJgMg$I4`$bITMXfHpGPE(1OPp;ePAk4x0_{tz3`a)pSw!ULpJ8Dr zAvvT{zWhq5%-b~Cxh#|6zuu1(g>ESi0V);xto9#^o39!5BjzYxJ3H~j51a?VbuS>dK_t zcCF!P@R#Zi}%1CiG|ovXKIFgFMuG=qKS@$Sy(Q8UFn^8M+8zTLZFp zR!-}ayce;U zq?MJu@20X)uSinSu#wGLrT1#q2evD6x(ftf`ZQjh_J4T^@xRBCti($%6z#x(P=-dX z`kheHm@RYu4-o$_=O6dpy8DhaUQl33?gqCzUUCj7e3axSzPlzq>>?k%A6&pNB3~Oi zNb@GAG}zZD!dAk`4Dnioh}=HPtTRpe&+KvhE3~Ys@3c1m&m8`M^qWg{>*8%asX#ej z{@dpMZ$wzezV#*8+h-Pib&Xj2x&oEXaX_1_HEO$msVNyP%?~HLW`-6i*wtr7w8Rss z&ntR;mWTX(K9#PIHN9XblsIy zm6~#_QyOzrgnuyBVhSYA)T!iXsQ1Fj*f^2w`+GGF2C?mf&Tw~RBbav^UpKkJ#t&rs z(d&BdEr8e^k+>7qc|M@Bq!XC3y_@xnV?`Pe&BR%m1Ffhv#EVI%Zq>>z!DXgCz#KDz z{0sq6sY&K=BcE?n>RC?>ay&2e%zi|=wDNO+;2aQY|~zvq9CzPVRutnf1B-D;w= zts0A&W>+A-U+h5r0Mx}~@o*|mSYQ%|CwNm3;Ar0X*anl?^yKU|m!*R6* zj1wgURADO;+ls5vj-OOUV%sbc7I^=#DW9#MR_7B)o&8>4ljRvh`DFov&EyOWwQVam5@9>H4<80zd5^?57T#c@$4#W$^8dJpbn2<12~s;S>H!rx@l8o^f?BK6 z9Z`x33A0a4O8-<3)zO!;&|kawMW}JL9DH+5^gUHRGT|p=3kYmfko5d7Vr;Iv_Kikg z4Jvx?rT)grM>R?K2*~$ZXVySeN+f1`0QvVkjc2?6GMNxs7=IzbGV8S#sgh0mhr$D| zh-XZ6MW?w2gkdVBRYcAZXQz?x^t4^b@3X$B*x$TBU|ai zO2EzE#z&e)j+>oi-K(V?Gkq23EqV9suELb&Vaq;0i;?3Xe`+>J&zs_NM6*|5gt<84W`BQry zy5aEtF!l=Y01}n4ytdpP3ke;+JbNi&&7dpZDvb2;MUT&sRyeCeH4rrAdf2%QgqCP+ z&2Mk`k^A+empB-n6hx%vXtAN&>61ULqOR~}JV_XZYSXo@C+mSBJ)97v3?p>pH*6&P}Ui#Is*EIoiV4zfhtI(B;dZ;G)+qqZEZ`~;tHGI+#XxKDfb&> zfAq=|zPHD6+7^mmtRuqf#4~QbCZ`vVLcX^rq^U=EX+?QKNP~Cu42dR7AM9XhZOpZ zQuKWm2OS=f_HiAzWEJz{I8r9mQ9(&{LM`g}s*uoBK;isDaGD z9&JA_5jPk;<-D2f{4d*5G=kGC5?=NSTpJtjYXNR+m!L}z^3hPzbo4dK31-T#rv3o0BJ!7+CZXP>-jb|4eW6&}?=qv*wr ze01yEdpP`;Jye==*dr5rzd<2p^EHWgcwVv#9y*?S-sFv6%CJ@el(55xS6ian^y`m=XaE; zV|-lLcFb)?1SsQTBc)p2BwV#FY{|?}A`$VVwqLt@Q9eG=S$%QJ|Nn@3%cwS^Zfm=g z0tJc`FIwEacp$jDySqCCZPDTscPMVf-90!Ix8m-B;P&M_=X=ikI~gN)#@PE_d#yd^ zyo|F6aGE^AXb#K<8bc5M=zo}Pl$#l3RBs&1oz`*ZKltnxWC&XZSoW*Gg_CmmzQ|rl+Syq8UBS(zU_)-oe}H zX$pcaOW!ZluzPp?_@w2Kk@-Hkc>DL=6}t%P=JKJT*TE{@hc0~E*VUfktZRI*ocCg; zR#4^V1caGBE}6cPH#P%$oMBhYn;8MyyVyF%Qk2CYXV#E|#%{W=-G$A{#Dv<#bb60z ztkPTSsAD^I5$OT%%c5Z7hv(OUBW?5zt$4=(1UUz|yUH1w$9018P-65X} z7^Fh-<&>wU9rNed`zM4=i0~)z{P`w=6S4ncX6`eBWMU=lA7N8rJ?`CBS&`5Kt7Si_ zI)|RGBqLPAh(fQMlL>fxfTFLiKQV{{)DrD$#sX5<9&Hqb)?I=E5&VzjNCBV+F{IF9 zhNz?v3mEQvMSQX_94&Tmh%2Uji3#67JhA>CN!+$p{Wt$5W7JC1Ig8qBq3#S{Oz{I zc9@@AY*GN3`2i_GI>3+n49#dx(y05KeIINTtr-Tol~tcNBV_-*s~;p`hIUA&JoXwS zfLhSKnGy9ZaVIj6SygycbVuia?u}0eKnge@i$j`PZbz!5jzfB0b`_F5#W+^`*RVZV z_xF5`&CVnE=RfC&I3?C~!F##-X2Ce6a7Rz!l0C^EqYNIt=l6gb4Z2fsb_4OBaJC$A zBr7bYQVr5q7R;xgkIUu@uFBlP2)=(mxWu}O_=B1pkch6=_}NNUeoE!r?XkgrJRU4ytj`+9T3tqH_O318!rPUX63;cSYh*bJliG) ze?DK`$DptPEsz+9R-q?TISl`Jy-L-yYErNbjK4{OoQZnU1jiPHvQie%7s9yut3Jb@ zS0Lu@_0is$IXmHPC&cXE1rGLggLna&!qW0DL@*BY`nzRU;)kP%;?~PP|r>fo! zbEEU59UII3rO&JFktJ->#URp3!EpT_-8Lt{ z5)OpZ5Sw~?lfT!r1T{=)R)3{)Wd)mBx*Oe=!#Bnf35VpLO*f-~=JBh=ZC)OWD1tj4 z@O|gDk|?trzD$CTdC##u#W<;C{hXYxgS&<$q-( z2?(Sjq(~+#&Y9CJmvxY07PI1YAFp9;XdGnRmJ5i_8St_&7F_W4pM5~!KvkG{}ui2IMt_j@?{ zJ+B!-h&k|^ru5ThZ`veYu>z-IxKv|r6+h{bl3d1mQBfvDJC5jpbnwSfctaBsIN~wM z5+F+2Serb&yrj~Iu=Ob|Rc_H`Y*Jh<%_md#fw#Uv%>H~Lr z4Y&dV)yJVqdH?nVh3KK+T!;wx;vKcHOL<8L(h_LUaTep3BN<`eR2`fTdA;h1k37^#Vg@_U?# zVa#pGVuQ!}Q9O{^B4S;vkBP)?*F=z-m~ZkaE;(c?`z3`2BMImYWl6#FD=@XVuHhj( za~htq2aZ+WXrYbN{}D~o=ds=ViUVFr#y72@0XWI`HA-ZV>|Twk0a+oUK{%ugw{IZe zxQG1D$;o5QR-TDwH>a=b*RLjhS>p8b@HnfDbZPZo3ji?&DJm(gQLd|~I ziIVG6a`V&sP0v%H#J`)w*zD4h$BGWZwv zO9s-mdN59(d|kXAVp}DsdqBN$b)Io7&~_LDqpj|23TnFnns=FuMUjJ{DwhwWvufK&LlzSyr)wus#eZ#KPR3Ij9?PomR~hlRFl8!fK05qc*bJH zkJ2l-KI$MDzQXZa#vuBxNBchA(6l&>3GCBU<2Q>2>*%Xk{y4Ecn3u;$PK-+hLN(Q3 zA-)~{DAq|&=OzX15!n$weN1rsl%{qpZ=(QB5DC!vZ1Hui^jI>fAwo(JDqe-1KASKh zc84KfzyK!6d!4af+oZB=w;pCXb8snJeFjDi5RQm_`byUJiC7=~GW_!NdnvJ0T|7kW zOT5nHF69RZ=qovzY}hoDF3ZoaXWylY)fI=th^0;_9V+^2HAxUW_%Q81s4u>3>k!(B zf;yxVj*WLJDI&2$`Lsn(>`;~oB;velBr=c*_aaur0yixM=bgVc2aj5GE(ctJM;K%# z1q4jL)YY?M+a}um*P+PM`l_x1dbG0l)rTBls^$8R6e|f4Z#KU#?{?LvegKE8_45!D zM<^;UB7-*@@`Wr6G3QkrGZOv@r!QMQX85kV60aK~0? z`og$MphcXsXW3_R+1`p&%Kh;4eJIW@VRPF|Z*oSkgREL?r|)+cUM5;`^A)0wdYUlA zAC=~!PBfvHk$YdL z1wHaewI5JLKrZ|eB<#YZtuvHzUZ%=!t=jJ-+V#APV zZ^D0?%6`RqYybz?%H$mxWTja8HFgqBhLHV9~OwZW0bPl%%_u@a$SRSgCvo+rq|&Z_`Kiz%P;ti6Un|#j_6ES)AuqC>9O6fWvU$3 z(E5zOeqd{Y3;)f^*JBbq6R|V&Jn^pXxPS?{_MF$gV7-Jifh?T5+S~YxK|1=qN5|&L z@%tMUz2DWR;Q=@Ym_m}$L})|_V^ncvG}xIcnG?RCvW6W9ZsmzjxEPR1*lqM1Rg1p3 zGo+B&zG~2f{JM_CwM@Rb^dStlI;M$ikVW-XD?iLJ2bErgqYXk|M4BzwVuBLKH{E0t zOWvg0OSn-vS&DyTeZ;7CpOUuEQw&+hIyQ7dPA%)F;zPs_V`VB?W_W)ZTj&g}V zq2ms`78h!HtzOBBE;fL$YA@GxQae$`!mFH9!fjM9rf?O5)dm+-=P{Syd+rK zLo=yL)Kssm&#g&?FFN|eFy}A9H6>;6V|;ko_K&DnhaNg@tZ0JuH`2-|N6yi2nfo?< z!aei4|LhBFCd2h(=;cl$@x-s_e`Misy`{WJ9 z>ts7kvI&xJb+pP5OQ`WX2m?XiG?XMgqa-Y~HKA^v^-yd+3*%$B9}{}6y8O7lkwxlc zeN#Iif4hDp6qbjz+LbYC&e`d(+wcDOoR4iJ>zTmp9?j$6W0^McVIEM#HoKTnYT&l}3r=o=IO6uB{cxOo*uy zvduW{Va#!|G`yzrzcLMM5P6+>^9+-Z40fCM`}2u=NE#a8^UA>#SX!8ZwkE1jzC#Mi z`tut4JKRhOoN#wl%V_B{Rb|Od`j>Mk^2(z!NqL=70(^s#Oy|~PZkW{~M_x)pO;@Pe>5@NBQ3# zc0$XCi9e-()H(16`>fIn>Lfp2y<+&Y{R_T9vUxdHzD#-xH9IDqK@8-teeeEgfF};8 z)Q>Fl3Fjgr6F814=xwt$w?dssXT9FyxCFkv=9_YS5*l!mJ~m#&{E#2ltuy=(6Hhzo zA}hyyUUOsSI_!hoteJ!o4&Akg$jeXLe8T(nnf0I9!u}hIkw$xNK<2A`r)ozGMT`U( zF1*ZI+TubWNk;tPef`yjtZbH;2IDlYN4@p>l+x=GVofE}tT;nA-Bio5S1Rj9JdY#p z`rBu1s?pfXPQCk-MLKoq=SBfJgm(14y&#fykY<5wiB@u-W#Qc5zw^~#wMh1%8U+&^ zZX&dEZ=0C6Aj7IgUBv2X3K>4;cZtZV9#Qyuc)BYyI7$L;A1M6coI(M)J< zC61GDB$C$JAYM9yQUKxA7Va{uU9@#Smc}@As^_6cyB2E3x9OK>;C_g4RE{wuLZVzQ zx2OJc)Z*90IJ*%tzr1Kjm7_+J5ZkU3jypL4cdftA5l$bQyTcI}C)T`ld~9qp%&M;% zmL*8S+eOXVVAp%*tS0J_)iogPnToc1kr)@S;GF0_^P{Wl+?lZQ z%!UxTiZoV6D< zK@mHi?cm@OL8^y@q0oO>aNihbvfWg@v-s8nrF72!iDg*N6FnLWh5>*-g>8Pd3wrtO z{ld9=uIFRw3MtFEvbUUKN1E3l3)m#qkS?;zSoJ#>u5$s9r}3<#XRyj};*RkZj>Y7L zLk-PZZuX`D*B#uMz-NK9%vZvL_3oD2LckE4k!BhZs;&^|fZ`%#saR-p_4)d=*v%53 zd@0da^&R7Ci!p19wnzq%ry}>qGzD6M+cRIx_T z{DJEzdy651*mXwMfiys#Y(;)Ho~;mvGW@#tJOMF-QM0Lf^FH_OB+#j;Y@$~-G_Uc8 zC*m8N77xL-PFL0k^CPg%>*I5`DdS?H!FV9T<%i zih+`dwp|o#=RT88`0Gs{Kh&Ft%;_XiA?Sc_=>EGx@Bsnso+SM*1vh>sj|8OTpXI|w zS*}m8b#Ij6yHt#11Nu)QoY8YD{QmrVuw&Saub#f%| z!Ia2Rdxj&4>)9N^);kNPk!tj8@V)Urq-VlkAwb2e7hdpv{Jr=0i_O}H-cVEA)P5m- zT=k{K@w9K3xSsUkAgZ=rT+J^Gu~)ocUZ8NS+m!nps<9SugmJf;yR7RF`NSn*C=V1q z?nvk%RF@Mo2oMQO?5vW^ocd;n;+dLN)n2zS99D$nfCB>D^TN1C?Cb06L#r@qHh^^J z3X{`-6D?hZ0^aOZX9Zw32c)w@Fw_B|LTNvAD9ED0y-eaW-v4BrqaXJ?1%0RS8V zgJr;=*y|lLzbrEICtl`p2+TI%;Pt|Sa|YCM;*7~sOBabJ{s*}Ja|-M)Vj#qP&(|&6v!B;7h^Ey zgaj*6I+L)+AnPWu!D%eKrutNfv9rhz@!LFWfw&^(2#R~ESas%0&}kw*zI?kcmK{^R zar=yc675eBIcLQmKyPmqtu_#DUVnO1#L!r#W&EO!RL4_0N&Q=k-s&=M>j5g9uohn>+|9m^_oDqx}cf0sG=A5#YR{!|_i{A$dLx@%4I}${D zO45jX4l)wwk3c`sZmlh*-fT%z(){+No^8m1NM$_+&}HnLDCn1OIa}$->#2v=($Kgq zSK0QcOP6NmRqV^7TQpS^n1C>XTeLvsYt}J7rg= zVife^pfepPQTnr&AwTI&I|q8dm!=6Zdx z%}ydi*}O%_T$^*b+&iW5ccf$J;rtw4`ZsqGA?Gv;K}v1Hr2QNL3(JG~GkOTzUQ(A> z`eS+sh5>Dd0yuJZgx8d$IBKV6Sncx;fhPXOA7w8TvrOVObU-CENA$*r1=>H9e8_8q zD%OQFH&cp+ylv5CBetH-5Rsf(|2(hAuh)Mx!~xl3-KXmerY%HUVUK0^-XRIa=!T%1 z^8>xb*4c%Y_IXZJyraoica1rd4?hA$K<){@mrl)yLebG#d3H(E3rb+IjG5b}TJnBE zjBAtWc^CooDbUnW&pw3t?H@oqB%xnvj(eQc zG$npi!`oCb(pHz8ym7ZAXkiV6Y@g;n1Gto3wI4l*u@?v1^Sc-)5G`1&UyOGP`IJ*} z9tFgd_i)Jd6X}z)pWo#J01O6)Z-l_W$3(Pf_$}maXH>oUFNqCDaUq|4KW8Z(m!Mba zw^n-W2Sl6a@)|Pko5eltJ_?H7My4m{Xig1BOkRBq!DUQx8W%HF#r^_lW?P zwcJkL+lIA{m;iHI9lzQoAuM<-&8i1*MPzGie|Zv&!HC!LFu=k?G#X{ZIb-``wT{#$ z;VB~#gJVB3_AhYmMfZr8IMScE43so`gCEQDu^&D<)&|>OVZ3&lya=oGvH!iM5vn%{ zvJ1(jX*7}+s~5A0o-Y2g|A`p4S;>UAAKDD36U+hR?HR-GZsz48y}tJag@qh7l7`{G z_BD(eUr)9KD^(bf`_udTpOjP5LeWB()L&L*6l7o2$;iN0FASGQXM~qY15*-EXYwh9 z$2oPu0;)jYAuM3DKr+d_{hAV2VEX;5vBZsIuMBAnF?X+EaRJjfdus$kDm@1ko^(DJiEw1DXI%4D*ar(^(*<7 z#wZ;3s}=Fc#b*Mo1yUe$BsK&g8oy1^}4qmNei)W;a9I9Pp>qFZiAxGu5#d;o9 z^QnbI#muKW$w7bYu!$kgzeh(`joF0ofqjJpDyk&z~{yfGf{Ruxvl zCe@WXXr_B_0^>J!Hoe|X%^-B2PgZy>30&y*2Yax_E{7l(8>qJKX{qP!ygW|((#zW( z^aj{E@l6>=OtB-RGbXJK&(A;V)Fb-TwMYqtn7E_+Gw-RGx~S?6uPj9RQT+l%iO}uh z#8!TsJ?@xcN)6#*P);uga4M>v@d1RO1(n7kZ)rU?k=Ge9$69C9?+hy1hXm3}V#J%oex0plfF;rW(B+mpUdMAX(G{EiNO7jy(02HhRJ)wwD9Q;W6=q`#3^p1{ z{I*?9uJXfgbtR^FO{ZVnL$E0Y)$-kbL4T& zc{2$p>vZVvxFQ@;BtU5CNZF_ru{H-hBLatt0R*z?|C3+kgLbZ|%e`UDB(_6YX^Lgy zel+fVUymN4Px|}(Tacl(r%hc>eH5}{cB7&YFF~@+S4QDoIauO+_6upXt}|XeW!5e+ z8=@1&mD>MGirvBEuX~ePMP5@?$_jwST=vLW8bw)4sczs4_1i|E&}#c?_lT1<>!W^+ z-;Zr#Y@n`0hQH9W_&2TKetF^rBds);R6iyIpnLazO^u_YJ5%b0_N4&(|A$_Bcf;8q zz1yiWT!6W9nT~jNTNx5?8(nx|t?de7%CN-GEfUJMN^F)=xa-)>5kV4k=LLEp3IN)p zFP}5^g#BQ8iR;A{&ixxfiMbNtKXmmd@N}KNKi6)Qw8>pPE?-cpvD+kfYi3=q88BO|1{yF|FwCQe8@ z2pu^c?W_4~meUcRKq%1CQTMfelcombE~7+dLG>={3?4+Y-@S7Yo) zSvhi|!7sIdAkI6Pc8(@IS_89F%Plz)e5wXesS>m2eUd*2Fe8^K5I=KRRXX+*F|7nNFO#Ke)hH&fE zu)x?a7rREIu;N)me^F>OUXY0fX?8EqL~)?AK9ouHWhlv?2rs236HKqa(NHvAiKBnB zWde2D<}*~Caj?uq`}Z%B5%TU}3Y^e=dW;WaRP^3-wmR=u0eHjo@AEwBUA054v>D1y zES&VBzS5l%o|5nCit^B!bi=qYNm5D941e@$E76qF8dd@FmQ^+U1p;L)dYb?`YBw?M zWIt-bUYVwtjVsA@#4Q2C1R)SaZhX=~VkHHPgnEseY;4;?GoC1kK#{LaC9^JHC!J(8 zs~Ou!YhL1S*3Dn`+HGOHtfd@>p;_M7o*SuAY1&Vjryelc`G2Dr;MaTuq}OHG6RzCL zpF7NM`I)AkEw{hzVmo}zJg<<(uu*YuIUrLmKkb{p#yV!k^_wI-GLTGy?`?5)2mn6( zTgVtS;#y8h;*bIl2h(;?53k*@BJasqG~KZ=_4<%+jpzoS$bZ(-*fJ5P`iJ`SMZ$|4mPss?0Cu>F zy1sdepC3@~U$?c4hmMJn$Bl=}392d`O0pdOoMB3z41=yXBcqS8B)w9D)4pNdC?Tdp zO-ejnLbsEP+Y4K!oXYtxQ=#8%t@efmC&|O>n=>8{t>#z$sZj~r9dQyuUd;dvM&0Ma?AfwEX8|XP{}f}Gral>4dNfe_e74`GXv>%g zZTd)+__Dk8wV=mgQUH`mV?QLYg@1jz2(czhx8I%(cLS$&;lKRgyVq>SVk;OnIQz&B55FDA0#m-frP&SyW&9 zh-Qv|GnDm&ug~+)TF7kq+xx1%xrYY2ct!;!qJh#R*ZkO9a%KV!@*=a$4$+E$_gYtq zt4Uscth+I8(QZF;)aJ^z59Cc}Fz$ZUhA)rA{7%#d`MqV&x+V<{KoD%JFYRi}6y!#n z`Ll|t{_I#@Hh!$yo2=sb?Ribo8$i0IRad;+-yeQ#Lm1p$=vw5|2%wlnS|H1yTiX3r z_7Qtbd2C^X?#a|FTev-A4PpMi+#DNKOvgfv=DP6gJpWn~*O+N$+>UcZow)mTziwWz zqH{On^CRNp=O;u5ynl<--b5^{RCdw+fxLe!Dqr&wLD4OZ--B4HCjI%YUpm(+(C?`Q z9*Q~|$S?B#M~`Dw0U`IBCJcTch97#TJ{gAGAX92p?)_gS8?xrqr}W)yoFJZEFTB!z zFH&p9_0S}f*ARf8=d9=}@ztjR7$czm^J5EVCiKULjqHItItZxvT{L6!zLWFYi;^JF z*cQ**Vo$Z387>HP(oK~_i}P_2h4sk;9J(CXw4Y|D@uZ!9ntGI02MEmnxvL}kWkMi6e~8ATL#__xCh-e#|bG>#jL_3O$7q=4{>d}aoM zRYvuxT#H^|e_mv0Mi60^RuVIcwU$MNi$TIr!cEb*Ab;q^$gWQq%rm%A^f&ym1#&VJ z*L@pf;y}F@xA+6raR$B#-7i84fe=#s9bLREmV-n>ypbp=KH5KMjRPfbJ~2NTYacWFTyu3&ReJK%X9aUgw|}LGK%N3$ z?l2xHlbvD>HGF$fO^IKo2tCH?wiR3N?Y{LSM#>ebHi~zlB$Z0b%PP+be=gHqQRZ0- z1H4(;0f#bYql|7&yqmZjVd2X*!;-royi9+4HOx0s!WUW6C%`xS)t}*34h7pvi==h* zN&#@<0TNUGke?1x{OHg4frDA5&_H5^L+d&SFA^lO>@yb-oX3(_Er$AcEaj=vb%=jCsmqHM6db>D-OlQiR2jl19F**~5CZL+OSg0b`|Fd^} z7nni|^@1fu9c~ChuHytTA#SQoMF$MtG?7!|gyXDjNv(TdBIm@DNk3n4CImJc*L6sV zkbw{(Qw&(QLT(0Mg_^kn2|-GpxS0V$IY-%#ZXe;#-6W0a0_k|Vq#$--MqcG?ps8)& zxkbieDoz~0vJhN1;NDZI^Q#@^iZ*R=gP};v3?xvz@m8HbTqpHJ_xfy2$Jg^B`J_;m z+Sx={-}y`8GsKX3RGXxGA&qkKl>bF^gbLN_5Sm~m3%;tSkQ+`ln7I6sG*Ct`?{pP`XGi5}DYw3;X~t+Q_;R|bFsC*uL*6?j z`Kud%HVl*E;U}L@*}su%ce7{hb@Of!L#7VVBI2(R64f!8mjjN0L$~Hc*&yE~n=Ndf zoe0*G%f)MJ&RphvSJHb_(DT;lFls5hxKrzD-@@Hu?|a#Ild8z`V;zwPE#%c{;k-y@ zgl6|Ov&g+EVN}TcQ{ol*&9m-3sP#)4y;rcPk>7DO&%gN#BTzD*7f&=vJ%5bZJA>93%=e{DL2{4dI+HtNip>Eg`u4z99jW(-p)7`Ns(4kyPW8` ztUJm{{$UM~3(9;SJ6}7G`v3z&X{x-;x%w_2b-W)a4-&q@7;rhDVy;a>TPry z8a2^+eScEF6yAbvW)$VR&_9h@#VBgT{4{?epNqrE{^zq+gd58|CK2^Y#p|r)^%*UP zMff6~FWVxQ$m_Gc%VsWHU{7w@;K?lf>j0>ceCm(P;Bqqd@R*wFe=obqHrMOLt`sAG zdR@jbJQo(UG36FFS?T)gM@}giO3uC6#Ckl3+C)FMu=z@EgDGcv+mkwcd=to-mI8=y36? zb*{|>P1y7Nr1(vs=pgpk-)k-+Qhix}*lLh*w3{0=aTqEzIyD()>sd z(4_uQb*0Nj!nt%&aREvy+x7>O6Wu~IHTrhv=OBqN2N z1s~Nb?HRkwVLeq`Z~}O=#MzqaVYV^+376MqO^ntmt7lF&gC8aYcNl)6gZZa&|4a~F zJ(I;`zlg3Ah;hpuwV;FB&{ujNU1t!}0F= z;vkblpP9jHQHL#OPRO3T!HtXny zNxshD{tfR_AeGmKgBJ*8(c}k`ETGYQp@AC zaR@jGHH0>q0v5{}d1Yh!rau5TUs$(mRd3W=bWlh7!(~m+9qVtW<#rt`;>8uQs1gG+)iF)JN2_8y%$As&=YsWb2eh=UF8BA$_itXuC8R zlrYsZ>uL>Am%gAp&Z@9+#6#)5E1T@$*B8$+#K#tr;Jc4FeSgxi)A!5I&r~HVMdn2; zNWD-Yhp0j7pLb9|(I$b<)X5YFp3=G7LzjJ){ejEZja*ZoN4_tpOQ~4JcPCg)uQOy? zTXf-uNFBf;YCKEMVhY|j=>=KbGo9u?iB856gD2Gb*OyP&f(qvId%9W{iS*3ef>NF8 zI)U!;Ly-?A#3YpLU87y!CK0zj+hW<_J{f@F1Bs_OkT%rPVlar!^5J!6XQBNs(hxxC z|7F}6B}0?ks4B;`cx(x=$$UV z&-G4N{dEib6efax%w`_5y`M2}h<>EX*CB;Y-fJi@@r$J*=_|j2wmISu-Hd<|L(-wW z2x9=f(hwf&&-?RzZ`#Rua1W5|2Z%6lGn*B&*$TRTM4b)JF|PZc zdS=vJff6W^ztZD{m=19*#mJ#qw37w#TIwX2djP;h_N|g2lEGSM1;63Et|%+lhy%M9 zlty>iN}`Hq09s2ctAzwyPdJrwMmJ-<9Mgw-2Z>~%?I9}q_cH9iZOKiWJTq+9hcxu` z;fLsLYuRQFzUpC`iJX+|NBUbSdaQ=yX~A7hE91n^xv3SOXep<5=1maXi1{OmRz711JO)YJa}$i^-B5jJ5>}6Ebq6FK2PNEjY9{Nrq3WwOQKq zEU?~{&s7__5!vPEe^71NUiV{cI$irx@pNNrwthXT9Z_wYjV^T7pUl&Z>iIqg5TApA zJM2YFBl=G6_(r3h|DrD-szvj|_T#&R*@zQ~I{aW+luty5KDQ!eIHd;rZpQn=n_Ix5p4r zLyqX$d`6a6N%sjWhJ=p?hLk6|LbR_eFalF9b8n8Y?*7Onjbk5rm(C!CO880WH7^2v z1v;kh+BD&X(*zsY@GTd2`0Y3SHR|s7Oltb&Jndp-?Hj~oa*OhPGOJ*4vw<IjVGq^1wPF^c9Ss>>K+t@PazigBjW$}5RFSWX_{0tu%bh6G4=Ah)Ao9bT^NP|GzMXx6WpCQHEl1Td`aXD+xR|+U7_p{qEokf=MYgLEHOWbd_1{7s#!Tc;+0dn3 z4m<@kA+{WktP;*4NE3=rd{5IT2KpZIcslinC;(sRr?AEuNXK_t&WARqE76O@~_L| zrSL@QSZ$sPpf$>Hb%TolKasv%jsHKN9=DN&53OFwY{Zwe*05*4?l%JOH0-wZx;^(i zM|CZ*|8k$W$P*~|4>D^XR0Uw2Q=eip>p9b@3#CmRaf>>7h$y|h2K93;QcY1rT+j*% zq~qj*RBoq8MN+H?l&NzOAj>5WUP}({BKy};mtkdky{87tv+|4h1JMD0zRy#ruRQ0= z|Ii^S@^Tw<)esKQd1(!)`i?t%RDZnEPV&pF&CwtAWb=mW@Ud;d4><1u%gQg4jTEgf zCS_>kE^)|E=T321&wZsFjJQ}?W@LzTmG8OW$y#r)mfM5Rq~Et&n;mHU`t5C;rnex{ zEl-LK>G!#VL+BW_zE@!v_@Z+uXu}1)9p5?gGM~xp9Y?+I&-w51 z9$!{}cw9~zOzy%j!ZjUzdD%@wD|->Ir6ZjbTy*t`0G)n6S40V~%(xYouNvg)YKw=|a88Ej~gDkQa*I~#3ycWhi-Sp>KAv0ke zTPWkMpWJ$D^Jg$C>R$*+Pj0lJ#;##(7BsCRxs`&@)GK$h?5FyM`P@oe!O-xb!Qkl$ zHHyRp2YWuL+RwvI(*i?C4v75yG#!}5+$fnaJHfr*aB57)<*#UyW3&ihq(oe{cv^_W zH-nlm_d3BeeAL*V>syThHGSqTVh`iW7|yVohI7$x=fgS>CnxLA zUy}GD$;)3#Z_(D9`bDC_xV*)4_)B1@G|%oxZnD2`G!Lw%-f{2#I?oOyQG%g-A(Fs{ z#KyS(0Fz{)${JdK*xYhr2g#{}s7>S|cxrB=jy|q$1|6q{1>=k+WFI3HGS)I58q7Uf z^R>?nL(XMQB3}@+;~gSg*}N{?Pr#MkY?4DCh-rp@*e|jo&LL2P7 zTJ@|2m_71}wpnKY`EO^-44>&M(H%lvG4H%6({sg@zNBNAsZf1DtRbT#lx)}{$(l7g z+Td*>qTU;RT_AQ10B-R22kzhWOqwV9>Hvm42PV7+O#-*^X3G~S1M$kcFjywyW=UrL z(}Z0UPYDTN9;MT+r%3oJ7v3B55Fs!aU0}`RpaA0nbJ%*JSN0r<3LljinsYR^>y@sZ zK5qzC(iJNkvvFq+LD>R(9{(nCip0S8@bCuY@MK?S5e&NlOR6-i(5r`FzckbiV!+}F&^~5jrjSl zI(B<%esmf7Ff}i`)*B$^AcmB?1j4-fKKBE3<;)yNJ>`B?VtX@XC98PA7c1tr%bo5p z+HW*kkDzGb2(+aTs*i(WG?K_r)my{8^Tp zUl0}szsKsMb9#6nECo672B3Ji{CrI}8dMVu44EkxV)RdE{UtD4Ur#H#m5pwmu1BD0 zuogmV@*3K~;Fuh~9}3&)6RJ1H^5H+Cc&u>-9>VeMrHMq8t#M52-rxQ_CAD5?v!!|D(OD z&4SgKr=WN~=p5QNe4P5hqp0_|3`o)0bs;A9)VT~oNT%f+usk}Lu;+%Kio2z7*h6lb zp1!dJJ=C*)Rq|9z0DLJ}`cpZNiQ_kkXaG&y`rbS3G1rB?yYfNr?9JIq%68X97M_S5 ziwVE%idFNd=&@4PD~{T$Sm*8(i}F$3qmPQnq4e$SY*FZ)%JleL>(Grte;0?Z>3uHQ zHT8e4Ul%VKM*5Q%Pck<|yd?iKvY)sf&%vqM%CRV4txNLr zag8Y7PG=aPhlVmJ@B02Im#tP2bSlMhSHbfLn8Ye6G{VgF&j^fqE_%%9e7fONkDgvE zP0+(Nj-GfF{xKV<&a%FUP#h_Wy~HnGC0(m~QewFz_Tb-p|;1jOj@{i)C^aMy?#Y=PJt9-!;qiD3HHI;@= zqb=AXX&9b{hwS-x=`&KrHZU8CePfdJVhBN2UT;};EcW88kF67r!hK(R>KJYFQs?-a z&cy=lTCZ;otYvP8Mv8?J`oQ^3dD&2fuME8@B^Ip#=^y;cgKLRx8oO!LRWOf*W zrpc=;Ga`0tULgRU5Fu+Kce-H&g(v-7J;;+U5`-$f$`Z*u)dGZzZxg-^SZ?*6Wy577 z@^$F|vhLFE)p=&+(BfkFPM#@lsNw_U30W1+Dl1MqzDF<-LFmStW}%8DO``adEP}(S z!OCBaCd=ouV2<9CT8IacmULx=x_qCi*~bc&Nx>X5$9{Wx#@&*^_-~vhw(mT#w;MuF zXNA*6E2}snMpEyE$Y$FMxF@=YYNxU; zXro>vUk-+^^?l*O&CDjt&)#RrA4()fKKt=m?X{?eJ%)~_J)%xe$pOu|RT+vo17$k` z_@DvIPs3TwY$I63?q?aG!%pU>%QhJ5nQ*_6hAh5#4fVQ_yGn{({)+B|UH>67x8(m$ zVhc*B0+ViT0x1c(fC}E5(=Q*Pit_2^3aWmY zXX3XPL^N*rEpMJC{S2+;?_z5Vao|$Lt-rS+S9sIun~<6~g0eN#Xc&Li$ZF7*T$Wv~ z>?S2UN>pnEhIRx{4JK&qEb5a1_;?oX6Db}2s*as!=#TJ^>6Zb4!0KadNm{tpOIZt@ zrc=|I{k=S0uKb=8$r&k^oC|_wVpR!?xePDv=q+f}BOsy?7~bclx8eaiCm-Hfy0U&1 zx)-Ex4~qe1^t5eI_gpOTmEOI(oy^X9S>Bi`ul9?K^AR_J$652QZ~H68IG3E;>*1EK z_25hS$7q2WM^4{GY(s{iZQ;aBZTRj$F*Q>hs;FovTBT&oeei?(FIoS7dQAN$wgsl& zBh7Cma~R6E#A%qQmP1*D4{@p>`0SO*D7rWIPbllyE?`rR583T&7Kk#(-a#Oi9o9}S z6)F8RBx_!&dy)h-iK11=1; zh{h_EG&FrnxeP6z`#M}KU)X}+5+tsk4X;-=Mp+Sm#s<)$x_wib6SY~cKAQ-Df4N7d zxZ+H_U?p`2c)tv2+I{fyO|6v^HOS0!El5Bzi`Iy-BN1bw0UE_86=TA3s8A635TcN< zuy(uUtob4%ESmBrs>sFgRQaSwuahHgK2j;D>KCo>hG{=+xu-MUp;2-@jCQk#REqr# z{q)81VT-pjD7rfjL2n^$1`jTrpCC=|r|%76Rm^cqYgr9RCp!2?hy?Xr6Xn_+*%}#= zLG)=x;bIAx;Bo9PQW5h1$JJX#wH0>T!Zk_@EiLX=+^x73cMEO_PH}fDUfkV^JHa(T zk>IYyoe;FR!6-blB}H>)MLdZim`M^;~hTUS#*eqoW724^_F z;pz-Vq$()9PuHqAA{|onC95yT=hnJ8*@mU)M#!&AltpC#_I)EeQ!{EbsnZ0nx^>vJ z`D&Q}c&O@gwLItgm15H_XtM(|FM=d#Ca*K3ZH@P`6g<{h3`N9jS%>ePKed}Ry{*4K zD)?*OD3_Xar+q2*{UKEtU1xw3d(O~MuIn?4zo{ENHbA@PW58H17K2I>ZzruU zd5Q;Pt=p*nsWACawFXuFJwHKGN?sql2(FHYh5Ma`Li(8MTK%0R?(Hx|lZNwcwI#Mb z`pIaQC(UzukD%tDd(*m?sDB1~rZu>lWX2x} zaGy$bYK*JSB0Oqu6|HlLFJoN=qfZTJ);fynxVBiciX6MYD6zIFp*ky~Klsze)Li+c zId@_d$r`$`zr)!+{o-KiF8vv|5tZ=dZ-oPQttlc3-A6Z|i0XJ>SgQ7l{~iT|S@vdM zJUg`5FK;f`m_)?YT&XNebzu1ncpBmBhiUYrqLGEK*4dY$pTYXW0+GF&FJPrS=^bsY zBRDw`^pdC^b`Rz5DO}qQ57$ji7YANv!|w5&4NO=%_qLI$c04^wUn8Qis>-*XH?Wo} zcP&ZP4)35?fYo#_)1O-`=>5eB-FOb{R^$PZq#q(cKeNvi>>nGjm%$qr;38IOrHk(! zhPppH&@!7a(HYI_<@A(`Zo<|b7^b4-5jl<=x6$so+bCGpiYP1w@XXCIpb1NVKVL@r<$ujn z_Ki+rb%A2H`r5`96I>yfANlN5kQa1)U8bR~B^Q5SR7r>JG=TlZdb7w83|+D=E_EU^ zB%rC3kg35y#UZg^IzK$FtHqyVE;g6l37V1W=$!O+QzZ!*G@(G*A-EqqnfP=-W0mk% z5$N6R7O-2TL-F-*>utpdz|VveS)-AuR7Rk2l&~(uV~D?zfLRz^C(u-9Y3*wr{(vP1zrnmnU8lx+7Bb=|{foz38}S z>!bDfkBm?eLkoTzIbN-erQ{D&*%+bioFjuWU%uozesSb0k=4RiZundoP!b+abqxt) zW%^dIzARL7k6Gfl74mwy&Qe(AWb8)Hvn$%7NBd0XHe3kFa=^PuaAECJ1yWKwed-7W zTDhW$kjYqZwe0}IQNo2f4)IXhEf1^(5Owrxy2Uaakp0ukzh~iBU-FaO`8Q3`GREIJ2V1?Pryg@fTHhB=rr4G`;Hrd&+0`%FXdTw-4u)-R^VuejVe)$Z*89TC!s2(OOnm=}X1X zw5X=ECw5^hpQUHrP>{5D(I3b0w*MBRI)@LqLsLavaFcTqzATXUpN2=Z0KD1N6~L;( zmg^kh93`~?@G0AnbB15Y3S=Q}%a39_a@5A7PHalAcl*0*$caOzHn;*mXD4v^ z(#{vtBN`Fqs0#aLx8hy4If94L_`RT0Ru)9lj*^h!;CF=ITURsd*KNCECE*+tUOWn1 zG_EK9pB|+bKfjXQ37MceUJAZx$h~KlzpD5d^T_i)L!vzFCul&lba7%Os{1-}&igNO zHgRu%w-~NxPJfZ0Tt%<4zSLa^b(ll}lIHQi@acBOu?ACcS%FHiBOZ&Y57DQ*`PdlD z^R|eYyKm(6^oyCo$gUk&Xh&_gzJ{i4J;R7m=MuZ1GFeh0S9Wi7yIP+X*FBb<$!}Do z*}tma#|)7f4oPrrgspPTrO8Vz|D-vycMzAV{UA9xiMv|_BliT*7iFIN8wmuXAm35ib@k`!?T*Ajg*vmPM8QDjAj0{f;+kw4X~EBLnPS z-gLk;WOn~};nI8GOLlFfSE0Pi8qEPduZ^H_y)fn9eOVT%s>6`n5ny$wKYVh?Urh-p zoxWfne>=k42+t`~&nWPB5cNzi+B+4Hvf{PKaDO@1FaqyBly*dk{c5#o^g)f_HM2@J zSB%I3lkI?0#;N=C^6WXrOf-+^K&_vTUqD%ZvJcr)c*}C zPwM1@O$Yxn%%(^DGh(MC*$r8@%K{GSc-Kd0Hyc!Nlx`~9Ab+YLN{lMFX9~A!u%ZEH zB-c1@bBV|{%+V-ys38j}48I99+~+Vx&*hq3WSdYEPFHLFX;*G}$g0`{Gd8wQvLKm8 z&xlD#uCB_ySNna}@I^ae^Hm04@?+D_!1L7uC$IdmN_cJGUVD`wM{3lQeC5DX2Y2=n zLAeF{>0M-n^(Z%HtI-;VSImDWOLx{%mI(!c7`GyGnlt05#CjIBAJSnez8 z`lsK*7xUc3*pfD__`H1v`g2Vxby0D!tm2^YD<9&Dhxn!&C9U^M0k?CkF#_b&9+b@j zaaKFX>wCB5*Q0|mSTaoVA93G32Ccbu)A_-W2rn-9l^&w8GL6%5dPgGe$D8|KAL~E5 z`QHc{JSbLwZ9n8d91%kfzb@|I(N$L%B1hC=v}KX9QHiXN-pUDD)FMJ z1m4G{e~q3+ed6!RZ-TfW$T5lPPRAuEt;22YGnsCFMU@W z6g5W<>H98Uf_)yy?~MO8pmS{!KUn|LX5`*U%L>R<#>|Q@Cl7x3R70SU(}jA3pSPP> z`#(d{=pA=L;|Y#S41IrNwEPQhc1G-g2W=WtRV141tGB@AU6wlU;)`y*7OeAlRf6oW zs9TIu+H<5{1Xcf(FP$k`A-jEU4Rf>J`cS$fl!o1{6{qu-$$NHD^|YmQ6q9kbTAGrd z*0yuW#Q|J>rE@I>J$mW$*BL+{xN+>)_ks=ox}s}yPuQ)vt?=foY0 zcTgoN@5pXTsiEGV&-T_fB5}^aeH`G(sL(K>Aw%N7TgW?}EE4jXsc_H+VrRuiI|i0p$$K4@&4d(r znscaBT;8S(RQ07&O8R@AL+_-;y%j918qvARd8lMH#j-6UQ_qE!+&JsX(cT#M2TEJ{ z>dd}&KA~*lrGC_n{CZN^n!-2wd>4-l^NS9K`6r-;^ zq2i)w9g=7NW0d!LP98NhMC*Ggj&BJYXML!iIrnAnnduq^n;xx9zM zyV-9wvPzC_2Pk0ZnVidP2&7+(YhyBmZ^5K#Emn$eF*20!Y+Y$?UlOv3dsgNed)%*d zWG75IB#c=muA8)Aj&aVB428(j`sxgG_a3~%u<^AXED@#HWyS@6g#Ex?aH+lIysAXf zj{8ai`i_NxSR3txN&s4xr#+dvRz=+Bo`+?%O!X<%5rF5hs?=S@B5t~`e2gjz2v+c( zDLk>PdWL`tBS<;;?(OEGV$NgH=2r>MJme!nRq7mB6sfokxB1IN!n&b^1&uy=f~o{@ z75@n(iY)qQ*8Z%}>~v_-4#s131|79qQVQ4{QoI27+C1^yUt{Qo(xn(3XL4;h7J=P+0{yM&5v2RbhKIkt$7!~M#tWVF`W|+@L3|84>8CV3Mq_`S9wohAS-qjc# z{8gx+xjb+Zs*qAORB{BhPglJ|)Ab{})KK1w5#`N#^C0w=*vm8T#hujzKFXA)8zt0t zc+JYrWR7X+T~s+cPC*tio$vR+0KLWv?V882mkX_)p|fWgo=cYwkvR9li@|A6_(kdp z#x@lE&k(;aeG(dtnm5TGJ-+kn;dN@|^SXUl{a(y>mOF}LLV5-zv|GhY`?4S@2w2qS znu|j04l*vO=$C0=6(-QDSBW7@>mlMXb^0-Y(FYnt?TqGL&Z*eh66k-K@zYxO7n5bH zh>{7Oylrkm(NEAfS<1v6%m+sG*nCTwnjh2>rLQaxYEw0}MqY6s70Wq%;|{B`Y&Kiu zoJ8Q+$MoihU9@?Jy<_X=ij7lYu`Q*QqPIo0nyqYfO#9Wf%EsAB>~7LDLKw)_^no#E zfp=a+cX9Pt^hDCw&OF8Xzp-R4pUqDyUvIaLwLPC?E$APGQ^_S;%a|pTv-P?fAs_ZC z9{%Xj3N5u*tG6S!SrZ+K1+~bKD-f1ty(Okb9vxvDqFpdbc5QI>^V1W8=@I=@jfCk~`5@*l5au+iDf7cE=vX5#OL- zHTu1w;htN*;Eovv8zxN+zPsw`enYP^L();asI1sRGIHWlj*bVCb6VZ${lw_HZKA!e zWBJ;O!*O}uhD23HC_tx%DG+kC#_ziJ%AGga)|MA*AK_YMcq~sk1cFMp6-EN*9(!!W z{?jBm5z7TL?p~j-Jrd4ZF%evF2gz{~sbD(|KEW4==QCS7=uKt5F4_*SaYe~a>RZ{) z{B#ozz2`wd^h!yCuK_jSOzFK%C!4G;E>&V?DAh)Cb%-%ut`3f6>v z+`mSimT3YDQfD1i>V#^9vsp0M0y?C3d}g3eum`%Hh-YWCHvXlATuN)BLVjw)om9N{ zg|}!G^CR{%bVI{?&D!xB=7n^kL*Q$;pZTX`riru}qMZD;H<}Ziy`mMh0#c;OJ^og# zw{NB^=ZgeZ;tX=o*v97hFMDUlMh4@E$-S5hf~lC8q!!{`xOg3>u%L^EL#St8FtHb9 zy58gUCTzC-2@x*wP}z_ox(?u(a-13!XwdnBC2SCM0~3gNv;xWwpHA#NZlm2BB#%)^v5$3R zED%#0=M`>L27&&H1ktLC2L5O>&=UbxB}9#iJc2V)RBCigZh+aiy(6J<*9zS-zwN?2 zk+^OU6tWFv92x9I(1Py@3=It1dv9Ru&N1hc6+ZIbgIzp6k@l8|X;s=zlVqB@EB6DM9lWC}W8FO4I8lIhrbJ0Er7LUjs_ zzVX;?O>EtMQx#xWWZ1KO;~dBR%QpL6`wup|Ek+sIWW`VakSZ>Jk|KMsm)ukE)$ncX z^W)5oc~$r}w`HYpszu_%xr%gQfu5{x8;0yyXS}_xR!Vyc9=_TgX_eaVR2y7_ zSHR~x!0IpG99Dg`wqx-bS?0q0cJ;jxSqJ&mD+Fs~9C4%I{ih=!XlU?g3I)%2kQP}| z)WY}W9D_B_CMXl|bW^I1*v%(%|0=~L@kc$s8^%Uwd4?0$vyJfk)KXhedY-}9#QA#y ztx-lP)Ksf=ca5;Ai-0%x&I1NdqG_27XgU5nO++@I6-y1wTt^~e< zdyNf~HWq=to9xZiv2>oa^Z_UmdSuuqA-K0aI0^nPxI4G;!fEus5 zGk>oBwbWVT5cM18a+N*VhS4_)p(3lw`a$!{7w7A>gupt&t_I#kDi(dl`O*N$FNKK| z?O0swSCU&lg(w2XgS{)LSUVFNwPH1$lzW+=100@QJLbtsKPg9hsz;kM1BU~P^Y^PB%xO582LsmgPlE)CQp^;`;NsrvlTPWsa z#x!F$>f;6>+n@Jzd*rl`cYe|lOOm(CgwojKeF#6x3#z7N@x;R38B9TtOQGZC|D5Nn(*^E3(Pz6>vzNl)L9EH$ zz8^nIsVs+^I)ttOTfQXcM~KU11s;edb-in2ekfw_RiAb-C~d4^Db^ZSqFV6DP&y>a zaK&A=wzEvQDX6dR-%tMzK`C`NP(4}vU4$#Q!q|t(x@KNf&#nj3IOHzIYD-AvAMPV@ z*shUekeEN2xzyg^4X=6ikDbfQ1Z zwWg8q=YBjx%wqS3>qP&^oGyEv8q9L_P-n_+CXiw6N;RIXy5Z77Id5sfdnlr1Oy64< zZ822e=%`RpElBF+-AE&*(!7nJ$U<*wO3i!eLURAzrhQ$z0=@*+6tt`_iA(QFw_EU< zIy0e-N*~UeI&J7i@ZO=yU+|g=xxssJg@!lph?u)0o_PjiH9xx3QM^_^DIHG3xX^^VP{AWz8q}6@f!YcndcdrCCud< zKh{agw`#~F8v~40{*3b7sbJ zO1)J^{N?SlZ;n1_eq?l05LGh=yHvPv@gva;z9AfL9;;Bruj3_n<`-7~kW7(Fwp}nw z*S2Wtb2YeWiK&8hKsl`uOGC$45wS>#4}vB0C)s7F*ur%>V&W-hp{o|h~d1{q|reI3$@ypsIdTEJ^$-4 zLdIFJLb9FB9NZwnsE;sS{hU2Zvs~#a4!}^N%BytYW!k;)P-M+Og_v+!hwL!^n~kv5X2vH2k;wh?ur<9VQB~ z29HG7&|*Q4MsYHQn1i#K0a!8OMBj?7tP@C_zY?gHTJB>0$p*$Q`k=S4^(RN& zpF0Cbs4gUxhbYvMlhss$Z%XD~3l_9fBH>kePNzr0@?;D%C7N4E>=GhnLjH%DUgv`h zklVmR9q^N}AI;I5OTMEqykEF`WB0!pvc|Mp>-ySdU|{S^{=)~nU#iYe?GvYHRw|ws zQ$Z+nS)i|_Lk{NeRL@+M9tra!9;9DNQnYSdzpM(&k83 z*UQ9n2tH)B#7h#;KtKZpYsjlR0SJLf-L^+LRVX0yq zoOPb(6X`C3maY? zNHtY{tS1$aNg06BSYJq@h3CC8U+iltyUZs(%j@$^g~_FVuqjA@ExdxPdu*aUl%{Ee zMe5(+6IU6|Y0@u~od4h~S((a|=ayk{&`88E$NlOQJmQ+B$xHR1#>&eOKx5XqU?C23 zZYnuj+}=*ibVDEf6Q!LRkrM88+KtDi=>Y6I`N>NcZd%i!r#0o=JfBy=s&uPBQ@b6{jZLuR<6Hk^0O3N3>3b93AKPuiWU`H z*erSuH_kSrEWEDwX@>Y^GyYYhxi8ZO=k}ntRiwhhEp>EgIO=AC7kRfPv>(8Yu3Pbk zqWhrYvajFG#VftDceqQ~7eCA$WR)!X6qZbJ`QgkC*pZ5Fy8Gs-ksQ-b17cHr_hlsg zrLa&}Lyo&4l%43db%-P`*y9wb{T%`D4Sg8KFC5*C^wXei^C-JSQXm z(mDSief@JzAYzFSfH(4~7-YoJub!AP7M266AHO3y*t49VPa2P?6#)1B&Mc4fV1aVA zPr$V*jUWPkY;!3etlmdP1~gOvQs>ct-Z$$!U4>tp=OpJh_mXQ=fwA+}LC_y@QGGM- z`WD{_3+K%;YSEt$YiaC4#^Txzcbwh%Bvs~ldyc>E6#wGyf>*9P7tM6jyVTLivLY)d z5*ii%$;LZ~ut&$<#fbl~l=-cRbyY$gSto^lT86ZZat&=M`--lmTPqq(xp2I;l)&M< zZp6}-H=OOBqfmx=&69xd?4h*y%J49g2tPi@4uFyED6149AHctKWJ3zmDHO7=XvTeT zxFrUn-q(#d{=>*Osxn`(k^2tyhDU$J)w`F@8MY9q;sfvFK0UHw=k`W>gUNlbmi=4p zGrN`{C$rMnDc;trm{ZlD$4@T$`8yQ&ACG*kmCU7qx(DiwL@y_)0nC}ltuQ5lS(CA` z1+OkrbH*>*z*v(FzKOm!xD#&BA*FG537H$su<*KUwiksG$v9aVI^J>8tS33{no zc@`Cu6tjz?I!PnP5TP^jL2oS)et5n*hJ4;zDpci0y$wGKGoLQfVer|B3QFr&!J^my z5J0bTMOAa?rJ;TQI;-~b^&WMWub(39b{XFCW7N5$!`?vU`cdzCdE^S@Uf*qSownQK z7TF4(NCaG+B%>+8XnAp&q;L+ns}dP4bfhOE<(~B$odp%9@Q)%FCl?*-za8Bcl&0uQ zX2EYxbNFU#l|@HyrFKBrr3p(ie85=EuMSE>4^!yhk&rMAM^w-HKJ3#)ercDFlK?nR zDl2E=T9l&WMvM1HqTIyiduuWqYcl#a4#pzpOo(nA&vrJ{!E#Px#Enb91Syjhk!Ta}n3 z5w^G=cmTU^MmP!S|4oH+9LZQz)CXPIb zC3}{CVE>?3K^O=FEnoSwJbf|=N;BuF`O9v6F;O2XsO5FJ>@LOXO|$JGNV zp=BBQw8WXNz?r{tI)&sBWsuSl0z;x0!*ZZ8j>&ETwI_1Je75sUZs{p!y7nq$*VJjI zsD>1s<|!FtcJ^Lz&JEthB)6I}cdKloh}j`a5xwxhVsq%b-Cqr> zVU@tebGFj8yvW7r!CN1J&Z4fQ6?%{U>QYC~v>EvmJbk(WIegT*YKnN38okX3l!0zh zh;(tp)ddyFgURl|0bg60i4f_RQgIV&EZ@g;b%g^82SXs{S{UbjF51Zfei}HiN1D6hC zDjULR@CDh-vw*SjMDqdWwBfT9&#xIN(U6r;>MDQjyrU!W^EMqw-Am8ZbK*eq*s!r8 z%r2JHV$>`r11}VJJe4A%-ltvgx2vdx9NgHZZnnbr)RKWl3o1jqI@t!QuAaD~1L0cH z%&P3517WTG*p8WPBXQLDbJ4kYZM1~VpuF#^co^X@)z#Ia?*hcR8OSBP8|iQxV+Fyj z0m%p_b*&e(IKeB_A`GLBsc+sp=!kU0rMbEWxwe)0tnv_0GD|7n9ydBLTV1sooe}T? z!ywONVNY#S{`VZi@HdD9|3kL9gK_TGcgNE2m#X>Wi&hDm)r@v$W;Y|D*uoSbh^Ly? zOSs9&*6f~{AK>w7geUVEsC@`0`KL~M{8tSy zV?d{bx}{QaiT^q2WCVd4^mzWNfcC>8LZP(!3Q7fDj58O%frs)7K$iDY|CY5 zJV}Rgd8gBjL=+|Xq&sN&Vls3&O1G?*n$Sze>MWrpz!x0wLgm6Aq78r>py;J8wTfaL z9z|{6$q!LlW^qL0#+tw!kEbhIL{{R7A3%exvrV(3K;iT_0A$UxYNA1!U-?TwbP@?e zYKpWGZ`+n=O8jWYwiJnaN^p8)xkNvJdT(1!HqM|Gf#ngb=`l>GOjIeFG@Zu{Km1*} z$|xny6`Mf~Wr5r$F?B&IObVxsN_yn zys$ewy26|cQ{NLOUHMgx|+Xij8F57DcTi!24mV&DNbb=-pqT!ig+9xTmh}%Zp z#j`s(wx4)1L*qboF1&Ekn(1KoxG3gfMcW0DAH-PGLjdB*Rj$*4x z5;k*AG?FJU{|Qk5{hAz0xJu!Uh7b5<-Df4 zb%Cq7aVotm_h0GY=^6uvaIQu6Fn1@MchbEMjWg@i7`k0m*NUa7Ge@ZB#UDG`dz|6x zZ-`ggyqh+0hx%dpgD$7NQ?SNmsAi`GjMmq`643+j)O%*kDEylH(bM21yR%c(jdBtH!S-EMg_BG+fx5$Qp-zM6+l`Ic8w{SkZGVIN?TK|y&kaTf*$bOTODWS5U8D5g>E)mgDEefr@{?1*IAUh@@{45bPl+ zcB;2hu%wn%$T0pl?SvvCPs`lmQjDgkf+x>_t7>_Vubh?y_cv0_vQ$)nz_jMY0MkH3 zKQ@l5*Kq*k1F*{|s5#>FOGVBZztaATs~T&i5R~(BC6!RBEja6yF4aMGH`?LCpX{$y zqB)Erq8`~YQ@?zdJSpu$H?is8Yw~ps?HaXr%AOre3^mkSL!hsn^+1*hYks$ugfp^@ zgYC8WFPOE~aI%pJyPLqO#uTZoqovVp)?znr+ z=Cj_&)8JPQP{f{>=$wTIU|R~Ih!+M$3D^28?eemxm8Y{{DrirzsKd3@Pvt zXC>F2NMpuP{^O4DLL#;E&r&``{C2kJRlHz@1v_u(zBK=~y?4B>?HDQ;!sSgebTM$wI`3IDS>D3}x*anl9(JU}3M%r~0Jdu^rfydT z^pJKU>u=w-Mb^C8{WOq7Yh|R!G{PN&?Y9n1QLe*dp{+j`P>TBNkssG;YLkz?TE+F8 z+%U;xd4G2|gS%SPc*bNU$ig@!w^G|^DFS>y$OJtwCLVOeGR%4*F$IUe(Wpt3!5ZWD zF7p7&%G-+CenV00M^TflUBk*90U_}Nt`jX_L{=$(k5r6(P^PSKH*Ol{QHrv++gdUp zPPF-%qZH;13BNSQl4{2>`T^U@bfu^~I8#0g3}ha4DX_-S_H2JvP-wlPA9ca_8YT25 z854JH0UUOJ4*gGj@lQQ35c|g-!H(@H&&=}#JFti(LtYeCV6r6`(7?Fov!6+?jyS@m z!5$p`$A_^g)Q>$sH(2Lcv?okbAUB~2YUt8@+;ljDi#M?3KRIkyUBomB1^Fv~^EQK; zHJFpIaJQhHsuIFnG+Y@5I4jV~@Q73PPT8UyDW~DPeFN}Mk^df!*z^PaRA3>bHg)mH ziktRVWlCFR9}<>4t0(C$04w8FBo!O#PHWih#;(T|CojT7^d;~8o!lWl3-BfGzvNSm zou3t@J7{oG$uw!I5j;|E!DHzbfZ&4zIW_Cf8KYyIkWFP4UZ!+DT%Iin=IqxqXOxEB z`m*#S0nNS3s(};yiCLtRE?ir~*hM`kI*B<9uA&}<+;iXQM+LX&l$rZ4lskkjtL!e% zSFkFJZ+Wx9b+8KDx6}F=u=)kf^gOnI9{fL#ydd&srxNi`r))lfbM%K1f`_H=3q8r$ zCE4%WqdfTP?`gXq+#b`!dz|HI;A?i?<-Sa`pIF_}<(6o(8`E5Ux36CxAVVEUl>MiRLLVQ(PeWp-ER$J9kXM(w(9YYPjjsmNN@@6gOHW!Mu5UGlJ67Z_Z zyoKF|(GA2rEzv#MmBv5N|M8(jO}=@ZBiGN34Ue*oz(aN=fNLlwu!h0{*E7@oG{-w&EpLjwI)vjPjP^!_ zHX#9FuRY=)?6IDIBu4d*Pj|dKU&pU)HF2#bHOfXul(dxtyD_U-d62|U*rx3O>8vhz zavF}o2Y6}3W{{Z%-um4t=ed0QP)P5*^_!*)PPgu1uljm1pur*3nYEI1ch2g_o=>k- zlYR>vAgGII%hMhv^)B_+b+9&v)m1nTqkrNdY2@@BMt8S%3*m#QGnBER`d_Dc6Mu$$jG1{fdikQM_EN#97ank2#z^d^&)pEefCB(9d|Hb7G zy4`QL+-0v#vsa4RT}G_3VB^KUs6Pb4eaqpT5Ts$n{bMl0!W#dFc#w9y)=h%+g_!J} zRJ@u>Hf~!UngB&IR?M4HILLV_uvV&YZ!R~Qv~;Rd;&$@bt2D@frhNeS_yF@M%o`3_ zS2`15UX)YSh%|2n@Dw9+U>ukt<{Oeqd1T6&HD)0sJuWtNYRMP!`X2C4?BjC!ay?d7 zYBzI#1vnv+fFHQn3!Di{lUy02eOTglcV>$elN6nlY124bf5iFs-%)0!o})$(~HQ|w%%p+rTU!u z2?b%DMDKmc05v$&QjawZXF8eL?tRRWI+}i7!40Av_)kISzuNheLwi?Qq?tNJd`86n z$RK@3lYIE8E3LbYmdbrIbUe^m_m;T2E#>7jptwC}ewRd&zpTYT0_#L{m0lzN%x zMN^y=37OJv$VC^s4S+8Y-%Ta`v+A>V<;n}$lKUPS=Xm^%o4KV)M?4eQ0sZX|)=u!e zN*&p_Y2cTZJmRx>sp+${#bt7qeeQveC@0JO+l8>$NBQbfed$?|%>3F+_3N{i z;XBvXLfJ_*^?a0a8m#n1vw2{0IPunRY)If!d;+ZWiG$ip{vI&`@W1Fr{If>YpXu(Z zO|lZVi{ABqrkq!#^f59(X09n2IHw=k-a-!*@F-nowwb zTdE)RKI&`}CMiHtLY?3c^knTXo|rL(KhhjA74)G?XExXMHSMpbyVr;dW~9TD_+{B8 z>dY$oMQ*~Lbv*Nf@DJ6Z$rKUz*^^NB+J39iVw>8>7RB4h;iS)-{5}5~P4z;qeti$@ zLwzKEY#ObTBH9p}eOL1x-DtGwqo<`pYj4w2cws_u0%$77&c|&WFxRnkGwElhMtOTe z&E0XkkBmA&+}$ur0$#V5B;)?i5Xb*~`4z+FViifjJ(5SEa4cnK)y*V};D(WD_|g

GO*pu0sp*nLnSA=3Z?=L~ZRaTbV~ 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

i6eh3i&wdjvoDP-&|*s+!urhBU+SxY90N{! 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

vb(d& z=@8N*3zKBMxJ~{f#eOx62#c9E!vvvS$gPz!(cVY#WvwNvIDcqGWd@8L;l`3ub3v)s zRT-Hay-rOE8a)JeMgXUuL^9{G|U8-h+=vuY}2&2q{L z4rBb;=MXf&3cPO?>XtbL4CJ%Xf=LaF`3)M}ub{WP`_mtNS~mXK&NCfWk!!Vo3q_kA>hf4O7RhPomkG=}RRHRf0rNw_iCR{|r~DUhaCwU_iFm zh*x-1;pj{>S>^{ckzrhj+=TMDDCS`d3FZFh$I+s5h8%NYwL zrYuP#S3X_gd`XtCq;7%rjl{>8ZFGLcNPP!I?0xxWi@s@P^B~Z8sIc9$>W9}7Im@X* zonv4498jXir8^$#zcz1F*Iwh_TX>0ZBA;|kO+81DaXvWXi{+UbQQviBg}WyCy`eA4@#W;2TF{5spOez2OVFdevow<)She{6iZiee+2#A z%HsE0w9OlQRR~UBGRk=tswA(OJe&4%g(0@vZjUk6<)y#pNS$i!dgY>L#72b4>`=l8 z=c=jk=rA2H#i|C$coL{5MBQe1UZS&^Oh;^N^hbxkWx(j_e)Ody>xUcRCF$KiZ`PN+ zZ>2QRKk8x4_c$5e6x`x}*pjk)G_C&Nh`{XilU>sLV1$-#nKWR?*t-fQIX*u6l0t5c z%;#SUdbLBIpR-K*NA<$}uNeepAOdKR|`JTP8J0;h2dP^|s*! z201t--#AVHRty7rg|eJ6_jDfS3Q=PH?^P7Sp`9AUnkFWKbrCI;3!fc1f=rJHc<9c@ z1*c+!c=3+k%#jm#5UzR^3R33C@-E&)PQ=U`n*xkZ!Y?9Sr%7W}^^A09JpLYvoG+ry z+vpm9q-ufBcO->OWbI(I(- z&iKd8r?G>*hZkDccOm4qW2&1^zct-D(sA`1MuW-!X(8*m9Mm3%9F*YUvNUen5FWvR zknG5i+L7+D?6bewu;nyhqf7DVCZlaM>Q7qmtSqnG zK#uxZt6rV1o#b}$9c54sMXdZnBNpS>JiAE5jQ1QIwO^iazqm2g=GGqv=cjxNKRvjH zirpsUXaan%%U%cT=a-niWWyLYN#Gxo5O8m~el`h`)NU6IEZ$c7f!NUw=accRy|QZP z4oyMey8z)QDQC_M0YK@7c_=3nF!^D(=~45wfKlR=fo*s3XeZYys*Y~xS(rM-77R1k zqLQI>3RDk?WZ=GSYs$n#NJ&d`%_o;~>UMnd?&%{CGPcB~e{Naq_1(_id`1zpE09*K z)l`_`WmPNDM2(dZVw+jN#o4f~mx{k4&v?nxm-O{(ov^%R!0zQ9=sL3<;Wh9%=VV7F zeNNGD7+iE022b4yfKy}0@2vBHK?#z3Ce(MP#AVz@W01>5&fyds?Yw@V?@`lX=Eq~A z;EnkLWLVHZS+~}Et_bcd2``Te&$W(o$aZb5-uYS<7NhLy`gFEH-S!msQ`KS0J9H%bQck`B!QzZivOn!5`wm@cL{9hSaw^i&pw}FT4oH|CHSk zNjQ8kxkm284FseH9Su>c{%Gtzch3bx{YttCd?3@GpK7PTSUI?Ms6aL05Q<6K;JwS% z>$z!O%kkiyjmUDHtv6^XU!iTy6#wE|0AwL%54~I8-$bx4-*vX~dU!M|b^Bu(S_`Ed z_oXo>>QX?_dJCqvY=+OUSDEm@om)QZ>L(8C5tB560R?C*tZXMQgN=|1_5Ix?Q~Jd~ zuj8QmT>cbKi-ZbsF8q3Ua|D2yU|~P`fF({Bv~`H$0T#j)475o$sl6a*dbTLeXmn(G`xuqP^xmQ~LJp*kU`VO9 zi%Xe*%LFuqgs`Cz7>1V#&F!F4&v2^{LO@5v#?=ZhHkaG7zAZ&Mj2;B8I~ztXyRtRF zE1L6OEb%iLq-igIxi1~cf>~0tYvG)nce1N>3E)}(#%x^Svhr2KCZ;p~2+D2>U-fxI z7?W1E7?o-!_6&<)R)%);w)Z*aw^hG?rbXB zLDl?B?ORgIF{%GLvZAZxGiq1lRi}2~g&MWK+C@t12SK?KM0B5Y49B z&t4D~K~sbxOcz$iHETI1es>xYnf{Z=@MWXaHTdBblM3QLmWttnpp(4x@#&Ql;)K@5 z%xg#ZmpW{APM8N=AHB`2&c<(B`tWO&ycT>E^p5cMxMzrZycuwczD5aoAl0cN{7Adc^kWmx6&XH1 z-f|7gTJ695%Ig~)BV+pp6_W~_cC?LiY$qbHSg-1Uf+UgK<_WrnKxtF8#df}tQRTxL z;umc6s=inUac-uH+SMk$l#-SDsz@*pmpk^3@NP~6**J`)=dCh~yHM$Bo5;9ID;*|b zhA9kS2#d@zv6*};y)-1p)pEZWuqs>l35M%?ebCVr@TEItW0pZ}Q? z3{bVp;IlBFnRWeHZ?e&8J3UI&v`o?@logksuawj5#+vT}Z=o~09`R>xk15w76D*mE zuXsmsZR5eOg7xX8Fl0u!+4JLu*@o%89#bk;fsJJiyFO3WRkI3}=>dMOJ_&IDwIcwv zQ?G@y)t3>OsPxTal&xJEIsV57ltU~jg|*AMOfRjxvAKrU(98@*CzIQQ zdUuF5az#5COoH|=6_^a{dn**TS?2K}^wv5aw9fZD$Kn_I(z(tnIMZ$)%NdzAe*s=|@hWRfK|Kr-?JdpuBs`MuDg4~rHoMs+6|5D4nuWG5I~RZ< zptI>L3TqjIo>uNWXsMjs;#AN~jSWr)8DK9fJHcq?JZ>w5mjiSs^#SA!EdW15X z`*hp%w~kerUbeLwz z(_0nKvNIwko9OEl%z}UgEJMK6g|Nmf-eSI=-fwKH2P`3}L0rtpyql*pG-96fNMoYAX7YckZ)>9xSkCUfsca{F{2fXj1lh-s`o9jmdDo@3CF%p zi3YEPf@9psT!`>Sp*^#hSKY6FyiYcI*;pgwp{o3W8LUZ~$sCkZ%i?}S%SHtShjuk- zYo(uZ1Ov;OMS``W%;s$}R(C zTLW&Ub+Il>(J9(y@>2o5D?iP?KVtc9TI>T!{i02?)+?%!$X}2USpD4Bi}|m z=0b#(g)e(c4;ca0U>G<{b^Hp#Jf+Q#+Hsj=O z-ODA$3ra|G0+PX?a1<76{c)pdgV|d)02tT8yf`jPU;wWEnVICQ-Vg`O1uf*B4WB; zOr|e2&FZuIyl>Pv=hU>m+Nd%x9UWI9D|uq_b@aI|KKq4}_D0JArb*#nhR;PlImTofA^tEeP+ZP-%_B zB&=vHOv(=yad%MN5TQOe*{^lNgC>mD9!_yWqABlonZ0ZFsYU7;9W+n}tZQ}@Etymq zal5qB!sKW!Wv!fU!SS;0y^F;1fZ% zK4)raiqHU4BN*G+w;cJym5vCjvm(lKJFJ69reuPlSj(dCQDdLe-~`Ex3^_9|uVj17 zT1ri{bEvl4qM(*_wG7bXQfT(Y6W|ZR5TAuKt_9zv=JOoEA>E=p15P>FB`e$`maH9z z6t7OjtZs{1I4tfI?AxQO1Jet(57m${c=ZBfCEHCq8oBG@Ru)C06$Oy=-GsUpXFe4k za`zz}tH3MFRrs7UuM>n zYATr0oBx_P+2=Y{ASLB(IO)EgqRwA{pYngEg%Uac0lXAkP{3g z9pr}zScSkMyqdKs$o8}M5A`CamtcY4h4|nAL;(vBU&7k!Fjy()o7?$GOWbibOSg92 zlB)EaJ$1xjX*~Ptwp|!*k!w!8dB$>e?V{b%Yp+RwDst>CU_*r`jM%0{W+6Q)4$KNvCoibDsYMbtF+rgHPKo#_Py66(t77G zZez^eCTha@`2Wp{?v;s~(FI1ofDif6!0GT4Nom3osAS{QRhmi6=B6*@wIPo%t_5JF+GIN>zt58*SE6QbEGH&LlDSI#MwvF0qV|sk2Iyx{yPfql~ zlmh+aRcaB+ZQwcUp*|WxnW&&MpVfH+Qnekzq`_e{=^9)rVsLS12uqz=t~xvUWbkFf zJ3g*U!6bDpMKfd?D%A4xkC8&MQx#iaiqo0LKTyM+XTCi=y;uIk5n()j_)e3~d)V43 zuJ+lXD7VHaIxQ6EKHP;Uu7dAqF-T2zXgxS1hvcC-Y~O*!uRM6>G?CXRHfD~Ja!eR& z*{xNNc@>hlY!)MXixMr@86w9r;zn*mXpGXVd~)e{;uKEuVs!MdfQk;^Rv0H;?0=#& zkh3irWZO{pFeJK*O9^n?+SSKsC%bLuxDvzUibSX#l%B7>*O~bgR=sk9mrDa zrZ&ug?khcpIo?}XpS|S}Gcv2{uJCVg@xyLK26ic_|9EceP>By9ai*0uRJmfo0Xnec_iJNEf~xK2-+Pf{BU2gu8=v%Itvt{?Jd zD|F^JX0A$t7#0@2NV1mWuSXl%*Yv{;ez(OT5pDd>?DnMoYdd5)Qec@0e|5l0K&o(v$7vMxzO@6{_(Ok# ztWkVtRN&9Cp^Z-A38adHZ1EKmS>bpmOUAlpf>OvwqdhxIdkvcNR7A*ORKhx9l~qp z)nU)J?YPR!6>Vq^?T@wZ*J_1W@C#N}^*3H&#CEbZ@v_S68oeq~G9V#oqD_3bJ(!A!pKUkO|mpj8{j+0yDn-Wwb{D+4P zbn*(b-fwR2&+L^52S(pfk!EH3fClu;?XkiN z`?1?H%B^D=_nHf6P97y@Vtn&D!elp$FWq>Bux|2VDOM^yj79-LkhU-FOFI~}bwmI) zfb~2lKV&7px-@=}Go+oc?+cM8$~ihUhd4}HT*~L!gT@aIx0D|UR8}(}+OAX`6!_sk z?8s(+W~eh$=p_1FazF3om^pv>C?eER$IR}FOEA0jD!kLsW%h05+2MKwYIFHwWvV9aT71XW%5tfW|X=em|&_3!3SWO`-zGEHQ;6Ra9)XQc{}JzUVHF$ z3bLIGub>IW+&d*IB`wpsGN|yo2bsJ_e^4?`J7LW^;$e`_faC{N7_%N#-%Sr9-g;kB zdV)j#fFH5%6!W=SkscCXOm}!l(tWF3CX@$SC-NWI3|`A!T};?oKBeT4J=e(G@ewY~ zztUom&wtwB4{pgwVzBSjS_A*LW1B`{5Zp1?*G7<08g+_S@>s%=>lB{7&Eq+p44Nnk zoy_dNpfrPhC`|~SG4^Rhe`U6E-+|JI1&8Mg)HekMm#ud`{jH2i4g3t=gR|;YW@@dQkK3V$dl6 z+j5?%vcsgm+I+;7zoKT2((ZQZkCYjEc3(ZXWxzoh8kC*70#5UTT2L*It={^-#XmENt5#sw$+kWfIaLhg?|e6%`eiJWAJNOaa6 z*U)OxfZc(U>{EGdMacL`vsCf9;F(X`k^t^a4A=?9G*)VkI-9)(7~hru7(f#b%#^cD zzRg}kH%vO7?*sBU@bR^ik<3>Wt7c35Mapvagwjg~{>}@4!g*l|^rG>JuUIIG+W-zsBL;~M>u zj@`u^Jn=LHn$xd)&U|3LP^XChW`K3(*szkGsW50RnxIHRqhAZ%@B#8b% zDuX&NeI`NQjB%;KIJG2Y(?gywpfv9Wx+cse4v$)xuvQ|-4g9@dH`Q)W^c9uGxrIt zH+o8*iSDC?NEbxJF)yuTc_~&7xWxWI0t&2{i{3Hq>0^8#fu^2~h>j^=wLsF~LENyd z$~zvo418tlXU&$2Qe_wqFLWfH1$`BAdvDk{9(pa6YDO_blaVD%)8m+Qa+-)mHj!BZ2jgGRpGi2`VPs)vok?zC zmr)_61%il_D>2viUeaAq^>5=ng10MIL=vE9kC23zfEiy}{d^N2hxYZ#z@R}bYhDej zI?5T@&aVl~Z*y=#8K`|SA> z1vPUIFbx&-TwD8>5$7Anf7|oE(M%eGgl+vG^u>0_g978Vx(ByiPzu38y?Jr3eAH?+ zA(~;~y&(3*byek$>BMJLLOvMucyU{0ebBTfpkM%0mSG@Y@>3qW^Ba{15I+q+_|Nu@ zQ1S(bBdi%;Y)!x^F4uqvQcyE9zgYinN7eDIkL}K0)JO8e=|X6Api3liw(^Ce)<2xM z_TBv{Dxn2Ww$rG=rp)sJ=Z4qtN^Ul91~mWChOJzPsbfn7l_02+t@)-8ZGU@nokDzEJ$0q~ z?s@2_>)6JE|BeAP`}YK=Gl=l;n@2P^zy&LX3S&Yf+>_~M4cT1 zAv^mawPPc;q&_hE^InscoS$LT)Q2w=Q5+8f*zNrDel?Z-TTqSSu5U36yuY!{tf4pi zE$(}BR9>03novxp$o*CSPqCRIvGKiAA!-VjP^q`!ca+K>c-*N#zH{W^y&lKTR>5Z% z@ineM+w65{aCB$$ya?)5DcMIEHoDbJdh-@3y|P{~FrBIlY)i(ZKYyT9CM)Z8v8Ww* zSVh=z*vZ;5@_x#CT!b=b<-y+G7j^#+LZcWhDz=h8hBCupEKB>)Ykg971%+YY1Fhf$ z79k?PG#eNr>(Bh_i1%HRydiqlaUIfJE1%COuFi&-@YPr}sy<0ZJ3Hi(6oEIqo?0lV zJkBo)m=FE~!Tu4XlP_}m(_db8_AREbdvf_$_C7f_`?vIVsUcI1P#==c@Eixa^kl<@b}gGzlf?F|Cd~@(}#XzwZC8d{}(y)zY!_bv}=R$`_nXZ#xFf9}tj`y>op#Ivj1CA`!8aJTiNrF7Bu3 z;Ty%4`R{HL-m)ON#X@{bLbEUMqFMNQi z-hv$!iHfv+Qx;tzvAHQUu=+*MCh*}#y~C3PiAEmRvV%~_VY4%tY7C}d@S|cK7;l3_ zI6W!r$thx;vU|)q71^Py1FE{!xb$b{%enOk5k~rtTi~JT2=Z#7_1kSl11j-(3dsYk zJHz!00aUD}Rma1{#Ml=X^$&ZnKM|^D!^&~vkqPOThQ_c$A5$@gvw&JgHpe{ZW-lA0 z#0Fe7asz~dv=rOtm1(3&|K^X}{pzyQ?lx}BF>cpiWxpWtRPxO*6+ePM;59B#_B)5t zf`(>0_aX6HPD#%px|RB?CXPEUM;G(H(9y??(s9vEd{87v*!p;i?_K-yggLubLf__* z98=VZr2dfuW7qvJ)ZP_{Ze2)!Br9-5a((8IpVLB9>gU&#XjG~4X?ELjmK$15W^p`w zTPzV_wY)r)H#J0cV<LvoqdYGYQ^QOp%HUTzzkWzht8&n5(CsMH$VHDG1&E zaG^zgN9s=#SXN%0A;%lGYuLD~&mxdKAk>XpDKDcorP~#W* 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 Date: Mon, 27 Jul 2026 08:39:03 +0300 Subject: [PATCH 13/34] Implements API, documentation, localization and quality Part of #16 Closes #25 --- app/api/openapi/endpoints/orchestrations.py | 728 ++++++++ app/api/openapi/spec.py | 7 + docs/api/event-orchestration.md | 240 +++ docs/api/index.md | 13 +- docs/architecture/event-orchestration-v1.md | 46 +- docs/index.md | 1 + docs/mkdocs.yml | 2 + docs/usage/event-orchestration.md | 1629 +++++++++++++++++ docs/usage/index.md | 1 + .../test_orchestration_documentation.py | 49 + .../test_orchestration_openapi.py | 106 ++ 11 files changed, 2796 insertions(+), 26 deletions(-) create mode 100644 app/api/openapi/endpoints/orchestrations.py create mode 100644 docs/api/event-orchestration.md create mode 100644 docs/usage/event-orchestration.md create mode 100644 tests/orchestration/test_orchestration_documentation.py create mode 100644 tests/orchestration/test_orchestration_openapi.py diff --git a/app/api/openapi/endpoints/orchestrations.py b/app/api/openapi/endpoints/orchestrations.py new file mode 100644 index 0000000..c383c79 --- /dev/null +++ b/app/api/openapi/endpoints/orchestrations.py @@ -0,0 +1,728 @@ +"""OpenAPI documentation for Event Orchestration control-plane APIs.""" + +from app.api.openapi.common import ERROR_SCHEMA, json_body, path_param, query_param, response +from app.services.integrations.normalizers.registry import SUPPORTED_NORMALIZER_SOURCES +from app.services.orchestration.actions import ( + EVENT_ACTIONS, + FAILURE_MODES, + PROCESS_DISPOSITIONS, + SUPPORTED_ACTION_TYPES, +) +from app.services.orchestration.conditions import SUPPORTED_OPERATORS + + +ORCHESTRATION_MODES = ["active", "shadow", "disabled"] +ORCHESTRATION_COMPATIBILITY_MODES = ["legacy", "hybrid", "orchestration"] +ORCHESTRATION_SCOPES = ["global", "service"] +ORCHESTRATION_VERSION_STATUSES = ["draft", "published", "archived"] +ORCHESTRATION_PROCESSING_MODES = [ + "continue", + "stop", + "evaluate_children", + "children_then_continue", +] + +ACTOR_SCHEMA = { + "type": "object", + "nullable": True, + "properties": { + "id": {"type": "integer", "minimum": 1}, + "username": {"type": "string"}, + "display_name": {"type": "string", "nullable": True}, + "label": {"type": "string"}, + }, + "additionalProperties": False, +} + +PERMISSION_MAP_SCHEMA = { + "type": "object", + "properties": { + key: {"type": "boolean"} + for key in ( + "view", + "create", + "edit", + "publish", + "delete", + "simulate", + "replay", + "view_executions", + "manage_actions", + ) + }, + "additionalProperties": False, +} + +CONDITION_SCHEMA = { + "type": "object", + "required": ["field", "operator"], + "properties": { + "field": { + "type": "string", + "description": ( + "Field reference such as event.severity, labels.environment, " + "variables.region, raw.payload or route.id." + ), + "example": "labels.environment", + }, + "operator": { + "type": "string", + "enum": sorted(SUPPORTED_OPERATORS), + "example": "equals", + }, + "value": { + "description": ( + "Expected JSON value. Operators such as exists, not_exists, " + "is_true and is_false do not require it." + ), + "nullable": True, + }, + }, + "additionalProperties": False, +} + +CONDITION_GROUP_SCHEMA = { + "type": "object", + "minProperties": 1, + "maxProperties": 1, + "properties": { + "all": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "All child nodes must match (logical AND).", + }, + "any": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "At least one child node must match (logical OR).", + }, + "none": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "description": "No child node may match (logical NOT).", + }, + }, + "additionalProperties": False, +} + +ACTION_SCHEMA = { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "enum": sorted(SUPPORTED_ACTION_TYPES), + "description": "Deterministic built-in action type.", + "example": "set_severity", + }, + "value": { + "nullable": True, + "description": "Literal value used by set/extraction actions.", + }, + "template": { + "type": "string", + "description": "Restricted template rendered from orchestration context.", + }, + "field": {"type": "string"}, + "source": {"type": "string"}, + "name": {"type": "string"}, + "pattern": {"type": "string"}, + "group": {"type": "integer", "minimum": 0}, + "path": {"type": "string"}, + "separator": {"type": "string"}, + "index": {"type": "integer"}, + "route_id": {"type": "integer", "minimum": 1}, + "team_id": {"type": "integer", "minimum": 1}, + "service_id": {"type": "integer", "minimum": 1}, + "escalation_policy_id": {"type": "integer", "minimum": 1}, + "notification_policy_id": {"type": "integer", "minimum": 1}, + "priority_policy_id": {"type": "integer", "minimum": 1}, + "action_id": {"type": "integer", "minimum": 1}, + "event_action": {"type": "string", "enum": sorted(EVENT_ACTIONS)}, + "group_key": {"type": "string"}, + "dedup_key": {"type": "string"}, + "window_seconds": {"type": "integer", "minimum": 0, "maximum": 86400}, + "seconds": {"type": "integer", "minimum": 1, "maximum": 604800}, + "reason": {"type": "string", "maxLength": 8192}, + "retrigger": {"type": "string"}, + "failure_mode": {"type": "string", "enum": sorted(FAILURE_MODES)}, + "disposition": {"type": "string", "enum": sorted(PROCESS_DISPOSITIONS)}, + }, + "additionalProperties": True, + "description": ( + "One safe built-in mutation, routing, extraction, disposition or queued " + "webhook action. Arbitrary shell, Python and container execution are not supported." + ), +} + +RULE_SCHEMA = { + "type": "object", + "required": ["name", "condition_tree", "actions"], + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "enabled": {"type": "boolean", "default": True}, + "condition_tree": {"$ref": "#/components/schemas/OrchestrationConditionTree"}, + "actions": { + "type": "array", + "maxItems": 128, + "items": {"$ref": "#/components/schemas/OrchestrationAction"}, + }, + "processing_mode": { + "type": "string", + "enum": ORCHESTRATION_PROCESSING_MODES, + "default": "continue", + }, + "children": { + "type": "array", + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + }, + "additionalProperties": False, +} + +DEFINITION_SCHEMA = { + "type": "object", + "required": ["schema_version", "rules"], + "properties": { + "schema_version": {"type": "integer", "enum": [1], "default": 1}, + "rules": { + "type": "array", + "maxItems": 512, + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + }, + "additionalProperties": False, +} + +VERSION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "orchestration_id": {"type": "integer"}, + "version_number": {"type": "integer", "minimum": 1}, + "status": {"type": "string", "enum": ORCHESTRATION_VERSION_STATUSES}, + "definition_hash": {"type": "string", "nullable": True}, + "definition": {"$ref": "#/components/schemas/OrchestrationDefinition"}, + "comment": {"type": "string", "nullable": True}, + "created_by_id": {"type": "integer", "nullable": True}, + "created_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "updated_by_id": {"type": "integer", "nullable": True}, + "updated_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "published_by_id": {"type": "integer", "nullable": True}, + "published_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "published_at": {"type": "string", "format": "date-time", "nullable": True}, + }, + "additionalProperties": False, +} + +ORCHESTRATION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "uid": {"type": "string", "format": "uuid"}, + "group_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string", "nullable": True}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES}, + "service_id": {"type": "integer", "nullable": True}, + "enabled": {"type": "boolean"}, + "mode": {"type": "string", "enum": ORCHESTRATION_MODES}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + }, + "active_version_id": {"type": "integer", "nullable": True}, + "active_version": {"$ref": "#/components/schemas/OrchestrationVersion"}, + "active_definition": {"$ref": "#/components/schemas/OrchestrationDefinition"}, + "draft": {"$ref": "#/components/schemas/OrchestrationVersion"}, + "created_by_id": {"type": "integer", "nullable": True}, + "created_by": {"$ref": "#/components/schemas/OrchestrationActor"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + "permissions": {"$ref": "#/components/schemas/OrchestrationPermissions"}, + }, + "additionalProperties": False, +} + +CREATE_SCHEMA = { + "type": "object", + "required": ["group_id", "name"], + "properties": { + "group_id": {"type": "integer", "minimum": 1}, + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES, "default": "global"}, + "service_id": {"type": "integer", "nullable": True, "minimum": 1}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + "default": "legacy", + }, + }, + "additionalProperties": False, +} + +UPDATE_SCHEMA = { + "type": "object", + "minProperties": 1, + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "scope": {"type": "string", "enum": ORCHESTRATION_SCOPES}, + "service_id": {"type": "integer", "nullable": True, "minimum": 1}, + }, + "additionalProperties": False, +} + +DRAFT_SCHEMA = { + "type": "object", + "required": ["rules"], + "properties": { + "rules": { + "type": "array", + "maxItems": 512, + "items": {"$ref": "#/components/schemas/OrchestrationRule"}, + }, + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + }, + "additionalProperties": False, +} + +PUBLISH_SCHEMA = { + "type": "object", + "properties": { + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + "confirm_catch_all_drop": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, +} + +ROLLBACK_SCHEMA = { + "type": "object", + "required": ["version_id"], + "properties": { + "version_id": {"type": "integer", "minimum": 1}, + "comment": {"type": "string", "nullable": True, "maxLength": 8192}, + "confirm_catch_all_drop": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, +} + +RUNTIME_SCHEMA = { + "type": "object", + "required": ["mode"], + "properties": { + "mode": {"type": "string", "enum": ORCHESTRATION_MODES}, + "compatibility_mode": { + "type": "string", + "enum": ORCHESTRATION_COMPATIBILITY_MODES, + "default": "legacy", + }, + }, + "additionalProperties": False, +} + +SIMULATION_SCHEMA = { + "type": "object", + "properties": { + "source": {"type": "string", "enum": list(SUPPORTED_NORMALIZER_SOURCES)}, + "payload": {"nullable": True}, + "headers": {"type": "object", "additionalProperties": True, "default": {}}, + "normalized_event": {"type": "object", "additionalProperties": True, "nullable": True}, + "event_index": {"type": "integer", "minimum": 0, "maximum": 1000, "default": 0}, + "version_id": {"type": "integer", "minimum": 1, "nullable": True}, + "compare_with_active": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "description": ( + "Provide exactly one of normalized_event or payload. source is required " + "when payload is used." + ), +} + +REPLAY_SCHEMA = { + "type": "object", + "properties": { + "alert_ids": {"type": "array", "items": {"type": "integer", "minimum": 1}}, + "execution_ids": {"type": "array", "items": {"type": "integer", "minimum": 1}}, + "version_id": {"type": "integer", "minimum": 1, "nullable": True}, + "compare_with_active": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + "description": "At least one alert id or execution id is required.", +} + +WEBHOOK_ACTION_SCHEMA = { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "group_id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string", "nullable": True}, + "url": {"type": "string", "format": "uri"}, + "method": {"type": "string", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"]}, + "body_template": {"type": "string", "nullable": True}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60}, + "retry_count": {"type": "integer", "minimum": 0, "maximum": 10}, + "private_network_policy": {"type": "string", "enum": ["deny", "allowlist"]}, + "enabled": {"type": "boolean"}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"}, + }, + "additionalProperties": False, + "description": "Secret headers are intentionally never included in responses.", +} + +WEBHOOK_ACTION_WRITE_PROPERTIES = { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "description": {"type": "string", "nullable": True, "maxLength": 8192}, + "url": {"type": "string", "minLength": 1, "maxLength": 4096, "format": "uri"}, + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"], + "default": "POST", + }, + "headers": { + "type": "object", + "writeOnly": True, + "additionalProperties": True, + "description": "Encrypted secret headers. Never returned by the API.", + }, + "body_template": {"type": "string", "nullable": True, "maxLength": 65536}, + "timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10}, + "retry_count": {"type": "integer", "minimum": 0, "maximum": 10, "default": 2}, + "private_network_policy": { + "type": "string", + "enum": ["deny", "allowlist"], + "default": "deny", + }, + "enabled": {"type": "boolean", "default": True}, +} + +WEBHOOK_ACTION_CREATE_SCHEMA = { + "type": "object", + "required": ["group_id", "name", "url"], + "properties": { + "group_id": {"type": "integer", "minimum": 1}, + **WEBHOOK_ACTION_WRITE_PROPERTIES, + }, + "additionalProperties": False, +} + +WEBHOOK_ACTION_UPDATE_SCHEMA = { + "type": "object", + "minProperties": 1, + "properties": WEBHOOK_ACTION_WRITE_PROPERTIES, + "additionalProperties": False, +} + +GENERIC_OBJECT_SCHEMA = {"type": "object", "additionalProperties": True} + + +def components(): + """Return reusable orchestration OpenAPI schemas.""" + return { + "OrchestrationActor": ACTOR_SCHEMA, + "OrchestrationPermissions": PERMISSION_MAP_SCHEMA, + "OrchestrationCondition": CONDITION_SCHEMA, + "OrchestrationConditionGroup": CONDITION_GROUP_SCHEMA, + "OrchestrationConditionTree": { + "oneOf": [ + {"$ref": "#/components/schemas/OrchestrationCondition"}, + {"$ref": "#/components/schemas/OrchestrationConditionGroup"}, + ] + }, + "OrchestrationAction": ACTION_SCHEMA, + "OrchestrationRule": RULE_SCHEMA, + "OrchestrationDefinition": DEFINITION_SCHEMA, + "OrchestrationVersion": VERSION_SCHEMA, + "EventOrchestration": ORCHESTRATION_SCHEMA, + "OrchestrationWebhookAction": WEBHOOK_ACTION_SCHEMA, + } + + +def tags(): + return [ + { + "name": "event-orchestrations", + "description": ( + "Versioned global and service event-processing rules. Group viewers can read; " + "group editors can create, edit, simulate, replay and publish; destructive and " + "webhook-action administration remains restricted to global administrators." + ), + }, + { + "name": "orchestration-webhook-actions", + "description": ( + "Reusable encrypted outbound webhook actions. Secret headers are write-only and " + "execution is asynchronous with SSRF protections." + ), + }, + ] + + +def _secured(operation): + operation["security"] = [{"bearerAuth": []}] + return operation + + +def _standard_errors(*statuses): + descriptions = { + 400: "Validation error.", + 401: "Valid JWT or API token is required.", + 403: "The principal lacks the required group orchestration permission.", + 404: "The orchestration, version or related resource was not found.", + 409: "The requested state transition conflicts with the current version state.", + } + return {str(status): response(descriptions[status], ERROR_SCHEMA) for status in statuses} + + +def _orchestration_id_params(): + return [path_param("orchestration_id", "Event orchestration id.")] + + +def paths(): + """Return all Event Orchestration OpenAPI path definitions.""" + orchestration_ref = {"$ref": "#/components/schemas/EventOrchestration"} + version_ref = {"$ref": "#/components/schemas/OrchestrationVersion"} + webhook_ref = {"$ref": "#/components/schemas/OrchestrationWebhookAction"} + + return { + "/api/event-orchestrations": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List accessible event orchestrations", + "operationId": "listEventOrchestrations", + "parameters": [query_param("group_id", "Optional accessible group id.", {"type": "integer", "minimum": 1})], + "responses": { + "200": response("Accessible orchestrations.", { + "type": "object", + "properties": { + "items": {"type": "array", "items": orchestration_ref}, + "count": {"type": "integer"}, + }, + }), + **_standard_errors(401, 403), + }, + }), + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Create an event orchestration", + "description": "Creates a disabled orchestration and its initial editable draft.", + "operationId": "createEventOrchestration", + "requestBody": json_body("Orchestration metadata and scope.", CREATE_SCHEMA), + "responses": { + "201": response("Orchestration created.", orchestration_ref), + **_standard_errors(400, 401, 403, 404, 409), + }, + }), + }, + "/api/event-orchestrations/catalog": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get orchestration editor catalog", + "description": "Returns accessible teams, services, routes, policies, webhook actions, normalizer sources and effective permissions for one group.", + "operationId": "getEventOrchestrationCatalog", + "parameters": [query_param("group_id", "Group id.", {"type": "integer", "minimum": 1}, required=True)], + "responses": { + "200": response("Editor catalog.", GENERIC_OBJECT_SCHEMA), + **_standard_errors(400, 401, 403, 404), + }, + }), + }, + "/api/event-orchestrations/{orchestration_id}": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get an event orchestration", + "operationId": "getEventOrchestration", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Orchestration with active and draft definitions.", orchestration_ref), **_standard_errors(401, 403, 404)}, + }), + "patch": _secured({ + "tags": ["event-orchestrations"], + "summary": "Update orchestration metadata", + "operationId": "updateEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Metadata and optional scope change.", UPDATE_SCHEMA), + "responses": {"200": response("Updated orchestration.", orchestration_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + "delete": _secured({ + "tags": ["event-orchestrations"], + "summary": "Archive an event orchestration", + "description": "Global administrator permission is required. The orchestration is disabled and soft-deleted.", + "operationId": "deleteEventOrchestration", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Orchestration archived.", {"type": "object", "properties": {"deleted": {"type": "boolean"}, "id": {"type": "integer"}}}), **_standard_errors(401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/draft": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get or create an editable draft", + "operationId": "createEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Current editable draft.", version_ref), **_standard_errors(401, 403, 404, 409)}, + }), + "put": _secured({ + "tags": ["event-orchestrations"], + "summary": "Replace draft rules", + "description": "Saves the complete ordered rule tree and records the authenticated user as the last editor.", + "operationId": "saveEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Complete draft rule set.", DRAFT_SCHEMA), + "responses": {"200": response("Saved draft with definition and author metadata.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/validate": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Validate the current draft", + "operationId": "validateEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Validation result.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/publish": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Publish the current draft", + "description": "Group editors and global administrators can publish. Published definitions are immutable and author metadata is retained.", + "operationId": "publishEventOrchestrationDraft", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Optional publication comment and destructive catch-all confirmation.", PUBLISH_SCHEMA), + "responses": {"200": response("Published immutable version.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/rollback": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Rollback by publishing a historical definition", + "description": "Creates a new immutable version; historical published versions are never modified.", + "operationId": "rollbackEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Historical version to copy and publish.", ROLLBACK_SCHEMA), + "responses": {"200": response("New published rollback version.", version_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/runtime": { + "patch": _secured({ + "tags": ["event-orchestrations"], + "summary": "Update orchestration runtime mode", + "description": "A published version is required before active or shadow mode can be enabled.", + "operationId": "updateEventOrchestrationRuntime", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Runtime and compatibility modes.", RUNTIME_SCHEMA), + "responses": {"200": response("Updated orchestration runtime.", orchestration_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/versions": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List orchestration versions", + "operationId": "listEventOrchestrationVersions", + "parameters": _orchestration_id_params(), + "responses": {"200": response("Version history.", {"type": "object", "properties": {"items": {"type": "array", "items": version_ref}}}), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/versions/{version_id}": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get one orchestration version", + "operationId": "getEventOrchestrationVersion", + "parameters": _orchestration_id_params() + [path_param("version_id", "Version row id.")], + "responses": {"200": response("Version including immutable definition.", version_ref), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/simulate": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Simulate an event against a draft or version", + "description": "Does not create alerts, mutate production state or execute webhooks.", + "operationId": "simulateEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Normalized event or raw integration payload.", SIMULATION_SCHEMA), + "responses": {"200": response("Deterministic simulation and explain trace.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/replay": { + "post": _secured({ + "tags": ["event-orchestrations"], + "summary": "Replay stored events safely", + "description": "Re-evaluates stored alerts or executions without applying production side effects.", + "operationId": "replayEventOrchestration", + "parameters": _orchestration_id_params(), + "requestBody": json_body("Stored alert and/or execution ids.", REPLAY_SCHEMA), + "responses": {"200": response("Replay comparison results.", GENERIC_OBJECT_SCHEMA), **_standard_errors(400, 401, 403, 404, 409)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/executions": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "List orchestration executions", + "operationId": "listEventOrchestrationExecutions", + "parameters": _orchestration_id_params() + [ + query_param("limit", "Maximum execution rows.", {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}), + query_param("include_trace", "Set to 1 to include full explain traces.", {"type": "string", "enum": ["1"]}), + ], + "responses": {"200": response("Execution history.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/event-orchestrations/{orchestration_id}/shadow-metrics": { + "get": _secured({ + "tags": ["event-orchestrations"], + "summary": "Get shadow comparison metrics", + "operationId": "getEventOrchestrationShadowMetrics", + "parameters": _orchestration_id_params() + [query_param("limit", "Optional number of recent executions to aggregate.", {"type": "integer", "minimum": 1})], + "responses": {"200": response("Shadow decision counters and differences.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/orchestration-webhook-actions": { + "get": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "List webhook actions for a group", + "operationId": "listOrchestrationWebhookActions", + "parameters": [query_param("group_id", "Group id.", {"type": "integer", "minimum": 1}, required=True)], + "responses": {"200": response("Webhook actions without secret headers.", {"type": "object", "properties": {"items": {"type": "array", "items": webhook_ref}}}), **_standard_errors(400, 401, 403)}, + }), + "post": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Create a reusable webhook action", + "description": "Global administrator permission is required. Secret headers are encrypted and never returned.", + "operationId": "createOrchestrationWebhookAction", + "requestBody": json_body("Webhook configuration and write-only secret headers.", WEBHOOK_ACTION_CREATE_SCHEMA), + "responses": {"201": response("Webhook action created.", webhook_ref), **_standard_errors(400, 401, 403, 409)}, + }), + }, + "/api/orchestration-webhook-actions/{action_id}": { + "patch": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Update a webhook action", + "operationId": "updateOrchestrationWebhookAction", + "parameters": [path_param("action_id", "Webhook action id.")], + "requestBody": json_body("Fields to update. Supplying headers replaces encrypted headers.", WEBHOOK_ACTION_UPDATE_SCHEMA), + "responses": {"200": response("Updated webhook action without secrets.", webhook_ref), **_standard_errors(400, 401, 403, 404, 409)}, + }), + "delete": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "Delete a webhook action", + "operationId": "deleteOrchestrationWebhookAction", + "parameters": [path_param("action_id", "Webhook action id.")], + "responses": {"200": response("Webhook action soft-deleted.", {"type": "object", "properties": {"deleted": {"type": "boolean"}, "id": {"type": "integer"}}}), **_standard_errors(401, 403, 404)}, + }), + }, + "/api/orchestration-webhook-actions/{action_id}/executions": { + "get": _secured({ + "tags": ["orchestration-webhook-actions"], + "summary": "List webhook action executions", + "operationId": "listOrchestrationWebhookActionExecutions", + "parameters": [ + path_param("action_id", "Webhook action id."), + query_param("limit", "Maximum execution rows.", {"type": "integer", "minimum": 1, "maximum": 200, "default": 50}), + ], + "responses": {"200": response("Redacted asynchronous execution results.", GENERIC_OBJECT_SCHEMA), **_standard_errors(401, 403, 404)}, + }), + }, + } diff --git a/app/api/openapi/spec.py b/app/api/openapi/spec.py index bd1415b..e88be73 100644 --- a/app/api/openapi/spec.py +++ b/app/api/openapi/spec.py @@ -11,6 +11,7 @@ incidents, integrations, notification_center, + orchestrations, notification_rules, notification_policies, oncall_health, @@ -52,6 +53,7 @@ browser_push, notification_rules, notification_center, + orchestrations, groups, sso, services, @@ -65,10 +67,14 @@ def build_openapi_spec(): """Build the OpenAPI specification from endpoint modules.""" paths = {} tags = [] + schemas = {} for module in ENDPOINT_MODULES: paths.update(module.paths()) tags.extend(module.tags()) + component_builder = getattr(module, "components", None) + if component_builder is not None: + schemas.update(component_builder()) return { "openapi": "3.0.3", @@ -85,6 +91,7 @@ def build_openapi_spec(): "tags": tags, "paths": paths, "components": { + "schemas": schemas, "securitySchemes": { "bearerAuth": { "type": "http", diff --git a/docs/api/event-orchestration.md b/docs/api/event-orchestration.md new file mode 100644 index 0000000..b7d548e --- /dev/null +++ b/docs/api/event-orchestration.md @@ -0,0 +1,240 @@ +--- +title: Event Orchestration API +description: Create, version, validate, simulate, publish and observe Event Orchestration rules through the IncidentRelay API. +--- + +# Event Orchestration API + +Event Orchestration is the versioned event-processing layer between integration normalizers and the existing alert lifecycle. + +The generated OpenAPI document is available at `/api/openapi.json`, and Swagger UI is available at `/docs`. + +For a UI-first explanation, safe rollout procedure and practical examples, read the [Event Orchestration user guide](../usage/event-orchestration.md). + +## Authentication and permissions + +All control-plane endpoints require a JWT or personal API token: + +```http +Authorization: Bearer +``` + +Effective access is scoped to the orchestration group: + +| Group role | Access | +| --- | --- | +| `viewer` | Read orchestrations, versions and executions | +| `editor` | Read, create, edit, validate, simulate, replay, publish and rollback | +| `user_admin` | Read orchestrations and executions | +| Global administrator | All permissions, including delete and webhook-action management | + +Every orchestration response includes a `permissions` object so clients can hide actions the current principal cannot execute. + +## Lifecycle + +A new orchestration starts disabled and has one editable draft. + +```text +Create -> edit draft -> validate -> simulate -> publish + | + +-> immutable version +``` + +Published versions are immutable. A rollback copies a historical definition into a new version and publishes that new version; it never edits the historical row. + +Main endpoints: + +```text +GET /api/event-orchestrations +POST /api/event-orchestrations +GET /api/event-orchestrations/{orchestration_id} +PATCH /api/event-orchestrations/{orchestration_id} +DELETE /api/event-orchestrations/{orchestration_id} + +POST /api/event-orchestrations/{orchestration_id}/draft +PUT /api/event-orchestrations/{orchestration_id}/draft +POST /api/event-orchestrations/{orchestration_id}/validate +POST /api/event-orchestrations/{orchestration_id}/publish +POST /api/event-orchestrations/{orchestration_id}/rollback +PATCH /api/event-orchestrations/{orchestration_id}/runtime + +GET /api/event-orchestrations/{orchestration_id}/versions +GET /api/event-orchestrations/{orchestration_id}/versions/{version_id} +POST /api/event-orchestrations/{orchestration_id}/simulate +POST /api/event-orchestrations/{orchestration_id}/replay +GET /api/event-orchestrations/{orchestration_id}/executions +GET /api/event-orchestrations/{orchestration_id}/shadow-metrics +``` + +The editor catalog endpoint returns group-scoped services, teams, routes, policies, normalizer sources, webhook actions and effective permissions: + +```text +GET /api/event-orchestrations/catalog?group_id=1 +``` + +## Create an orchestration + +A service-scoped orchestration requires `service_id`. A global orchestration must not specify one. + +```json +{ + "group_id": 1, + "name": "Production routing", + "description": "Normalize production alerts before lifecycle processing", + "scope": "global", + "compatibility_mode": "hybrid" +} +``` + +## Conditions + +A condition tree is either one leaf condition or one logical group: + +```json +{ + "all": [ + { + "field": "labels.environment", + "operator": "equals", + "value": "production" + }, + { + "any": [ + { + "field": "event.severity", + "operator": "equals", + "value": "critical" + }, + { + "field": "labels.priority", + "operator": "in", + "value": ["p1", "p2"] + } + ] + } + ] +} +``` + +Logical keys are: + +- `all` — AND; +- `any` — OR; +- `none` — NOT: none of its children may match. + +The OpenAPI `OrchestrationCondition` schema contains the exact operator enum supported by the running release. + +## Actions + +Rules execute deterministic built-in actions. They can mutate event fields and labels, extract variables, select routing and policies, change grouping, suppress/drop/pause processing, add notes or enqueue a configured webhook. + +Example draft: + +```json +{ + "rules": [ + { + "name": "Route critical production alerts", + "enabled": true, + "condition_tree": { + "all": [ + { + "field": "labels.environment", + "operator": "equals", + "value": "production" + }, + { + "field": "event.severity", + "operator": "equals", + "value": "critical" + } + ] + }, + "actions": [ + {"type": "set_team", "team_id": 4}, + {"type": "set_priority", "value": "P1"}, + {"type": "set_label", "name": "orchestrated", "value": "true"} + ], + "processing_mode": "continue", + "children": [] + } + ], + "comment": "Route critical production alerts" +} +``` + +Arbitrary shell, Python, SSH and container execution are not supported. + +## Validate, simulate and publish + +Validation checks the current draft: + +```text +POST /api/event-orchestrations/{orchestration_id}/validate +``` + +Simulation accepts either one normalized event or one raw integration payload. It does not create alerts, change production state or execute webhooks. + +```json +{ + "normalized_event": { + "source": "webhook", + "title": "Database unavailable", + "severity": "critical", + "labels": {"environment": "production"} + }, + "compare_with_active": true +} +``` + +Publishing creates an immutable version: + +```json +{ + "comment": "Reviewed and ready for production", + "confirm_catch_all_drop": false +} +``` + +Catch-all drop rules require explicit confirmation. + +## Author metadata + +Version responses distinguish the people involved: + +- `created_by` — created the version row; +- `updated_by` — last changed the draft definition; +- `published_by` — published the immutable version. + +Each field has a matching `*_id` value. User objects contain `id`, `username`, optional `display_name` and a display-ready `label`. + +## Runtime modes + +```json +{ + "mode": "shadow", + "compatibility_mode": "hybrid" +} +``` + +Runtime modes: + +- `disabled` — orchestration does not run; +- `shadow` — decisions are recorded but cannot change production behavior; +- `active` — published decisions are applied. + +A published version is required before `shadow` or `active` can be enabled. + +## Webhook actions + +Reusable webhook actions use a separate API: + +```text +GET /api/orchestration-webhook-actions?group_id=1 +POST /api/orchestration-webhook-actions +PATCH /api/orchestration-webhook-actions/{action_id} +DELETE /api/orchestration-webhook-actions/{action_id} +GET /api/orchestration-webhook-actions/{action_id}/executions +``` + +Secret `headers` are write-only. IncidentRelay encrypts them and never returns them in API responses. Execution records expose only redacted response excerpts and safe error text. diff --git a/docs/api/index.md b/docs/api/index.md index 9afd087..78430a2 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -41,11 +41,12 @@ Read more: [Browser Push](../usage/browser-push.md). ## More API documentation -1. [Services API](services.md) -2. [Escalation Policies API](escalation-policies.md) -3. [Matcher Suggestions API](matchers.md) -4. [Sentry Integration API](sentry-integration.md) -5. [Voice Call OpenAPI Notes](voice-call-openapi.md) -6. [Business services](business-services.md) +1. [Event Orchestration API](event-orchestration.md) +2. [Services API](services.md) +3. [Escalation Policies API](escalation-policies.md) +4. [Matcher Suggestions API](matchers.md) +5. [Sentry Integration API](sentry-integration.md) +6. [Voice Call OpenAPI Notes](voice-call-openapi.md) +7. [Business services](business-services.md) - [Heartbeats API](heartbeats.md) diff --git a/docs/architecture/event-orchestration-v1.md b/docs/architecture/event-orchestration-v1.md index 245b7e6..7285ac0 100644 --- a/docs/architecture/event-orchestration-v1.md +++ b/docs/architecture/event-orchestration-v1.md @@ -884,35 +884,41 @@ Every final field should optionally store provenance: ## 20. API surface -Suggested endpoints: +Implemented control-plane endpoints: ```text GET /api/event-orchestrations POST /api/event-orchestrations -GET /api/event-orchestrations/{id} -PATCH /api/event-orchestrations/{id} -DELETE /api/event-orchestrations/{id} - -POST /api/event-orchestrations/{id}/draft -POST /api/event-orchestrations/{id}/validate -POST /api/event-orchestrations/{id}/publish -POST /api/event-orchestrations/{id}/rollback -POST /api/event-orchestrations/{id}/simulate -POST /api/event-orchestrations/{id}/replay - -GET /api/event-orchestrations/{id}/versions -GET /api/event-orchestrations/{id}/versions/{version_id} -GET /api/event-orchestrations/{id}/executions +GET /api/event-orchestrations/catalog +GET /api/event-orchestrations/{orchestration_id} +PATCH /api/event-orchestrations/{orchestration_id} +DELETE /api/event-orchestrations/{orchestration_id} + +POST /api/event-orchestrations/{orchestration_id}/draft +PUT /api/event-orchestrations/{orchestration_id}/draft +POST /api/event-orchestrations/{orchestration_id}/validate +POST /api/event-orchestrations/{orchestration_id}/publish +POST /api/event-orchestrations/{orchestration_id}/rollback +PATCH /api/event-orchestrations/{orchestration_id}/runtime +POST /api/event-orchestrations/{orchestration_id}/simulate +POST /api/event-orchestrations/{orchestration_id}/replay + +GET /api/event-orchestrations/{orchestration_id}/versions +GET /api/event-orchestrations/{orchestration_id}/versions/{version_id} +GET /api/event-orchestrations/{orchestration_id}/executions +GET /api/event-orchestrations/{orchestration_id}/shadow-metrics GET /api/orchestration-webhook-actions POST /api/orchestration-webhook-actions -PATCH /api/orchestration-webhook-actions/{id} -DELETE /api/orchestration-webhook-actions/{id} - -POST /api/integrations/orchestration +PATCH /api/orchestration-webhook-actions/{action_id} +DELETE /api/orchestration-webhook-actions/{action_id} +GET /api/orchestration-webhook-actions/{action_id}/executions ``` -All condition and action schemas must be documented in OpenAPI. +The generated OpenAPI document includes recursive condition-tree schemas, the +safe built-in action enum, draft/version author metadata and write-only webhook +secret headers. The dedicated public API guide is in +[`docs/api/event-orchestration.md`](../api/event-orchestration.md). ## 21. UI diff --git a/docs/index.md b/docs/index.md index e21351e..c873639 100644 --- a/docs/index.md +++ b/docs/index.md @@ -85,6 +85,7 @@ Read more: - [Channels](concepts/channels.md) - [Browser Push Notifications](usage/browser-push.md) - [Reminders and Escalations](concepts/reminders-and-escalations.md) +- [Event Orchestration](usage/event-orchestration.md) ## RBAC summary diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index b15c126..3b8cbc8 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Voice Call: integrations/voice-call.md - User Guide: - Overview: usage/index.md + - Event Orchestration: usage/event-orchestration.md - Profile and Personal API Tokens: usage/profile-and-tokens.md - Browser Push Notifications: usage/browser-push.md - Voice Providers: @@ -92,6 +93,7 @@ nav: - Troubleshooting: voice-providers/troubleshooting.md - API: - Overview: api/index.md + - Event Orchestration API: api/event-orchestration.md - Services API: api/services.md - Escalation Policies API: api/escalation-policies.md - Matcher Suggestions API: api/matchers.md diff --git a/docs/usage/event-orchestration.md b/docs/usage/event-orchestration.md new file mode 100644 index 0000000..26bc9f5 --- /dev/null +++ b/docs/usage/event-orchestration.md @@ -0,0 +1,1629 @@ +--- +title: Event Orchestration User Guide +description: A practical guide to routing, enriching, suppressing, delaying and automating incoming events with IncidentRelay Event Orchestration. +--- + +# Event Orchestration user guide + +Event Orchestration lets IncidentRelay make a sequence of decisions about an incoming monitoring event **before the normal alert lifecycle finishes processing it**. + +A monitoring system usually sends facts such as: + +- what failed; +- which host or service is affected; +- how serious the event is; +- whether the event is firing or resolved; +- labels and integration-specific payload data. + +Event Orchestration can use those facts to decide: + +- which team, route and service should own the event; +- what title, severity or priority the event should have; +- how alerts should be grouped and deduplicated; +- which escalation, notification or priority policy should be used; +- whether notifications should be suppressed; +- whether alert creation should be delayed; +- whether an event should be dropped completely; +- whether a safe asynchronous webhook should be queued. + +You do not need to be a programmer to build common rules. The **Builder** in the Event Orchestration page provides condition and action controls. A JSON view is also available for advanced definitions and API-managed workflows. + +!!! tip "A simple mental model" + Think of an orchestration as a mail-sorting desk. Each incoming event is inspected from top to bottom. A rule asks a question such as “Is this a critical production database event?” and, when the answer is yes, applies actions such as “send it to the database team, set P1 and add a label.” + +## When Event Orchestration is useful + +Use Event Orchestration when one or more decisions must be made from the content of the event rather than from one fixed route configuration. + +Typical uses include: + +| Situation | What orchestration can do | +| --- | --- | +| One integration sends alerts for several teams | Select a team, route or service from labels in each event. | +| Different monitoring systems use different severity names | Convert values such as `fatal`, `high` or `disaster` to your IncidentRelay severity convention. | +| Important alerts need a different policy | Select a priority, escalation or notification policy for matching events. | +| Alerts have unclear titles | Build a consistent title from labels and extracted values. | +| A noisy event should remain visible but not notify anyone | Use `suppress`. | +| A transient event should wait before becoming an alert | Use `pause`. | +| A known health-check event should never create an alert | Use `drop`, with careful testing. | +| Related host alerts should become one incident | Set a common `group_key`. | +| Repeated events must update the same child alert | Set a stable `dedup_key`. | +| A diagnostic or ticketing system must be called | Queue a reusable webhook action. | +| A new rule must be tested safely | Use validation, simulation, replay and shadow mode. | + +Event Orchestration does not replace IncidentRelay alert storage, incidents, escalation execution, notifications, correlation or impact calculation. It prepares the event and selects how the existing lifecycle should handle it. + +## When not to use it + +Do not create orchestration rules merely to duplicate a simple route matcher or a service default that already expresses the requirement clearly. + +Keep the existing configuration when: + +- every event from one intake route always belongs to the same team and service; +- a service-wide default escalation policy is sufficient; +- a normal silence or maintenance window already represents a temporary operational condition; +- the change is personal notification preference rather than event processing; +- arbitrary shell, Python, SSH or container execution is required. Those actions are intentionally not supported. + +A small and understandable rule set is safer than moving every existing setting into orchestration. + +## Where to find it + +1. Sign in to IncidentRelay. +2. Select the required group using the global group selector. +3. Open **Event Orchestration** from the navigation menu. +4. The list shows orchestrations belonging to the selected group. + +The summary cards show: + +- total orchestration definitions; +- active orchestrations; +- orchestrations in shadow mode; +- definitions with an unpublished draft. + +Use the search field and the mode and scope filters when the group has many definitions. + +## Permissions + +Access is calculated inside the selected IncidentRelay group. + +| Role | Event Orchestration access | +| --- | --- | +| Group `viewer` | View orchestrations, versions and executions. | +| Group `editor` | View, create, edit, validate, simulate, replay, publish and rollback. | +| Group `user_admin` | View orchestrations and executions. | +| Global administrator | All permissions, including deletion and webhook-action management. | + +A group editor can publish changes, but only a global administrator can delete an orchestration or manage reusable webhook actions. + +Version history records three different authors when applicable: + +- **Created by** — who created the version row; +- **Changed by** — who last changed the draft definition; +- **Published by** — who published the immutable version. + +This makes it possible to distinguish the person who edited a rule from the person who reviewed and published it. + +## Core terms + +| Term | Meaning | +| --- | --- | +| Event | The normalized monitoring signal currently being processed. | +| Orchestration | A group-owned, versioned definition containing ordered rules. | +| Rule | A condition plus one or more actions. | +| Condition | A test such as `labels.environment equals production`. | +| Action | A change or decision applied after a rule matches. | +| Draft | The editable working definition. It does not become the active production version until published. | +| Published version | An immutable snapshot that can be used at runtime. | +| Execution | One recorded evaluation of a published orchestration. | +| Scope | Whether the orchestration is global for the group or attached to one service. | +| Runtime mode | Whether the published orchestration is disabled, evaluated in shadow, or actively applied. | +| Compatibility mode | How orchestration and the existing lifecycle share responsibility. | +| Disposition | Final handling decision: process, suppress, pause or drop. | +| Webhook action | A reusable encrypted outbound HTTP request queued by a matching rule. | + +## Where orchestration runs in the alert flow + +The simplified flow is: + +```text +Incoming integration payload + ↓ +Integration authentication and normalization + ↓ +Group global orchestration + ↓ +Selected service orchestration, when a service is known + ↓ +Existing IncidentRelay lifecycle + ↓ +Alert group, child alert, escalation and notifications +``` + +A global orchestration can select a service. IncidentRelay then evaluates the orchestration attached to that service. A service orchestration can also select another service; service handoffs are protected against loops and excessive chaining. + +Rules inside one orchestration run in their displayed order. Earlier actions can change values that later rules inspect. + +Example: + +```text +Rule 1 sets labels.environment = production + ↓ +Rule 2 checks labels.environment + ↓ +Rule 2 now sees production +``` + +Order therefore matters. + +## Scope: global or service + +### Global scope + +A global orchestration belongs to one group and can process events before a service has been selected. + +Use it for: + +- choosing a team, route or service; +- normalizing severity and labels across integrations; +- applying group-wide suppression or drop rules; +- setting common grouping rules; +- dispatching events from one shared integration endpoint. + +Example: + +```text +IF labels.application equals billing +THEN select Billing API service +``` + +### Service scope + +A service orchestration runs when that service is selected by the incoming route, the legacy lifecycle or an earlier orchestration action. + +Use it for: + +- service-specific severity or priority rules; +- selecting a service-specific escalation or notification policy; +- adding service-specific context; +- suppressing or delaying one known service signal; +- queueing a diagnostic webhook for one service. + +Example: + +```text +For the Database service: +IF labels.operation equals backup +AND event.severity equals warning +THEN suppress notifications +``` + +### Choosing a scope + +Use this rule of thumb: + +- choose **global** when the rule helps decide ownership or applies to several services; +- choose **service** when ownership is already known and the behavior is specific to one service. + +## Runtime mode + +Runtime mode controls whether a published definition affects real events. + +| Mode | Behavior | +| --- | --- | +| `disabled` | The orchestration does not run. Drafting, validation and simulation are still available. | +| `shadow` | The published version is evaluated and recorded, but its decisions do not change production behavior. | +| `active` | The published version is evaluated and valid decisions are applied to production processing. | + +A published version is required before `shadow` or `active` can be selected. + +Recommended progression: + +```text +disabled → publish → shadow → review executions → active +``` + +## Compatibility mode + +Compatibility mode controls how Event Orchestration works with existing routes, service matching and policies. + +### `legacy` + +The existing lifecycle remains authoritative. This is the safest upgrade default for installations that already have routes and policies. + +An orchestration configured as `active + legacy` is not applied to production events. Use simulation or change to `hybrid` when you are ready to apply decisions. + +### `hybrid` + +Orchestration runs first and can explicitly set fields and selected entities. Existing lifecycle logic continues to fill values that orchestration did not select. + +Examples: + +```text +Orchestration explicitly sets priority P1 +→ the existing priority policy does not replace that explicit value + +Orchestration does not select a notification policy +→ normal notification-policy resolution continues +``` + +When an orchestration result contains an invalid entity combination, such as a route from another group, hybrid mode rejects that candidate and allows the legacy path to continue instead of blocking alert processing. + +This is the recommended mode for most initial production rollouts. + +### `orchestration` + +Orchestration becomes authoritative for routing decisions. A valid route must be selected before processing can continue. Evaluation or entity-validation failures block processing rather than silently falling back to the legacy path. + +Use this only after shadow and hybrid results have been reviewed. + +## A safe first rollout + +For a first rule, follow this sequence: + +1. Create the orchestration with runtime mode `disabled`. +2. Use compatibility mode `hybrid`. +3. Add one narrowly scoped rule. +4. Save the draft. +5. Validate it. +6. Simulate both a matching and a non-matching event. +7. Publish the version. +8. Change runtime mode to `shadow`. +9. Review real execution traces and shadow metrics. +10. Change runtime mode to `active` only when the observed decisions are correct. +11. Keep existing routes and policies during the first rollout. +12. Use rollback if a published change is incorrect. + +!!! warning "Start narrow" + Do not begin with an empty catch-all condition combined with `drop`, `suppress`, `pause` or a routing action. First match a specific source, environment, service or alert name. + +## Page and tab overview + +Open an orchestration to see its workspace. + +### Rules + +Build and order conditions and actions. The rule card summarizes: + +- **WHEN** — the condition; +- **THEN** — the configured actions; +- **AFTER** — what happens after a match. + +Rules can be moved up or down, edited, duplicated or removed. + +### Simulator + +Test the draft without creating alerts or sending webhook actions. You can test a normalized event directly or pass a raw payload through a supported integration normalizer. + +### Versions + +Shows draft, published and archived versions, who changed and published them, comments, definition hashes and publication times. A historical published version can be rolled back by publishing a new copy of it. + +### Executions + +Shows real runtime evaluations, including source, final disposition, matched rule count, duration and trace. Shadow metrics are also displayed here. + +### Webhook Actions + +Lists reusable outbound HTTP actions for the selected group. Only a global administrator can create, edit or delete them. + +### Settings + +Edit name, description, scope and service. Runtime settings control mode and compatibility mode. + +## Create your first orchestration + +This example routes critical production alerts to a selected team and service and marks them as P1. + +### 1. Create the definition + +Click **New orchestration** and enter: + +| Field | Example | Explanation | +| --- | --- | --- | +| Name | `Production critical routing` | Use a name that describes the decision, not the integration. | +| Description | `Routes critical production events to the platform team.` | Explain the operational intent. | +| Scope | `Global` | The rule selects ownership before service processing. | +| Compatibility mode | `hybrid` | Existing lifecycle fills anything the orchestration does not set. | + +The new orchestration starts disabled and contains an initial draft. + +### 2. Add a rule + +Open **Rules** and click **Add rule**. + +Use: + +| Field | Value | +| --- | --- | +| Name | `Critical production` | +| Description | `Select platform ownership and P1 for critical production events.` | +| Enabled | checked | +| After match | `stop` | + +`stop` stops evaluation of later rules in this orchestration. It does **not** drop the event and does **not** stop the normal alert lifecycle. + +### 3. Add conditions + +Keep the root group as **ALL** and add two conditions: + +| Field | Operator | Value | +| --- | --- | --- | +| `labels.environment` | `equals` | `production` | +| `event.severity` | `in` | `["critical", "fatal"]` | + +For `in` and `not_in`, enter a JSON array. A comma-separated value may be accepted by the builder, but a JSON array is clearer and avoids ambiguity. + +The condition means: + +```text +environment must be production +AND +severity must be critical or fatal +``` + +### 4. Add actions + +Add these actions: + +1. `set_team` — select the required team. +2. `set_service` — select the required service. +3. `set_priority` — enter `P1`. +4. `set_label` — name `orchestrated`, value `true`. + +Save the rule, then click **Save draft**. + +### 5. Validate + +Click **Validate**. + +Validation checks, among other things: + +- condition structure and operators; +- field-reference syntax; +- regex safety; +- action parameters; +- selected route, team, service and policy references; +- templates; +- dangerous catch-all drop rules. + +Fix every error before publishing. Warnings should also be reviewed even when the draft is technically valid. + +### 6. Simulate a matching event + +Open **Simulator**, choose **Normalized event**, and use: + +```json +{ + "source": "webhook", + "title": "Checkout API unavailable", + "message": "Health check failed for checkout-api-2", + "severity": "critical", + "status": "firing", + "dedup_key": "checkout-api-2:unavailable", + "labels": { + "environment": "production", + "service": "checkout-api", + "instance": "checkout-api-2" + } +} +``` + +Click **Run simulation**. + +Confirm that: + +- the rule matched; +- the selected team and service IDs are correct; +- priority became `P1`; +- label `orchestrated=true` was added; +- the final disposition is process, not suppress, pause or drop; +- no unexpected rule matched. + +### 7. Simulate a non-matching event + +Change `environment` to `staging` and simulate again. The rule should not match and the event should remain unchanged by this rule. + +Testing non-matches is as important as testing matches. + +### 8. Publish and shadow + +Click **Publish**. Publication creates an immutable version. + +Open **Settings** and change runtime mode to `shadow`. Leave compatibility mode as `hybrid`. + +After real events arrive, inspect **Executions**. When the decisions match operational expectations, change runtime mode to `active`. + +## Conditions + +A condition decides whether the actions in one rule should run. + +A leaf condition has three parts: + +```text +field + operator + expected value +``` + +Example: + +```text +labels.environment equals production +``` + +### Field references + +Fields use safe dotted paths. IncidentRelay reads JSON objects and array indexes only; it cannot execute methods or arbitrary expressions. + +| Root | Example | What it contains | +| --- | --- | --- | +| `event` | `event.severity` | Normalized event fields such as title, severity, status and dedup key. | +| `labels` | `labels.environment` | Normalized labels. This is usually the easiest place for routing facts. | +| `raw` | `raw.alerts.0.labels.namespace` | Original integration payload, when available. | +| `variables` | `variables.application` | Values created by advanced extraction actions. | +| `route` | `route.id` | Currently selected route metadata. | +| `service` | `service.id` | Currently selected service metadata. | +| `team` | `team.id` | Currently selected team metadata. | +| `integration` | `integration.source` | Integration source name. | +| `time` | `time.now` | Runtime time context. | +| `result` | `result.disposition` | Decisions already produced by earlier actions. | + +A bare simple field such as `severity` is normalized to `event.severity`, but explicit roots are easier to understand and maintain. + +Common normalized fields include: + +```text +event.source +event.title +event.message +event.description +event.severity +event.status +event.dedup_key +event.group_key +labels.alertname +labels.environment +labels.service +labels.instance +labels.job +``` + +The exact labels depend on the incoming integration. + +### Operators + +| Operator | Meaning | Example | +| --- | --- | --- | +| `equals` | Actual value equals the expected value. | `labels.environment equals production` | +| `not_equals` | Actual value differs from the expected value. | `event.severity not_equals info` | +| `contains` | A string contains text, a list contains a value, or an object contains a key. | `event.title contains Database` | +| `not_contains` | Opposite of `contains`. | `event.title not_contains Test` | +| `starts_with` | String starts with the expected text. | `labels.instance starts_with prod-` | +| `ends_with` | String ends with the expected text. | `labels.instance ends_with .example.com` | +| `regex` | Safe regular expression matches the value. | `labels.job regex ^rabbitmq(-.+)?$` | +| `not_regex` | Safe regular expression does not match. | `labels.namespace not_regex ^dev-` | +| `in` | Actual value occurs in a JSON list or object. | `event.severity in ["critical", "fatal"]` | +| `not_in` | Actual value does not occur in the collection. | `labels.environment not_in ["dev", "test"]` | +| `exists` | Field is present, even if its value is empty or null. | `labels.cluster exists` | +| `not_exists` | Field is absent. | `labels.owner not_exists` | +| `greater_than` | Numeric comparison. | `event.value greater_than 90` | +| `less_than` | Numeric comparison. | `event.value less_than 10` | +| `greater_or_equal` | Numeric comparison including equality. | `event.value greater_or_equal 80` | +| `less_or_equal` | Numeric comparison including equality. | `event.value less_or_equal 20` | +| `is_true` | Value is boolean-like true. | `labels.customer_impacting is_true` | +| `is_false` | Value is boolean-like false. | `labels.maintenance is_false` | + +Numeric comparisons accept finite numbers and numeric strings. Boolean operators recognize booleans, `0`/`1`, and common strings such as `true`, `false`, `yes`, `no`, `on` and `off`. + +### AND, OR and NOT groups + +The Builder provides three logical group types: + +- **ALL** — every child must match; this is AND logic; +- **ANY** — at least one child must match; this is OR logic; +- **NONE** — no child may match; this is NOT logic. + +Example: + +```text +ALL +├── labels.environment equals production +└── ANY + ├── event.severity equals critical + └── labels.priority equals p1 +``` + +This means: + +```text +production AND (critical OR p1) +``` + +A NONE example: + +```text +ALL +├── labels.environment equals production +└── NONE + ├── labels.namespace starts_with dev- + └── labels.namespace starts_with test- +``` + +This matches production events whose namespace is neither development nor test. + +### Catch-all rules + +A rule with an empty condition matches every event that reaches it. + +Catch-all rules are useful at the end of a definition for defaults, for example: + +```text +If no earlier rule stopped processing, +add label orchestration_result=default +``` + +They are dangerous when combined with `drop`, `suppress`, `pause` or mandatory routing. Catch-all drop requires explicit confirmation during publication. + +### Regex guidance + +Use regex only when equality, prefix, suffix or membership cannot express the requirement. + +Good: + +```text +^rabbitmq(-[a-z0-9-]+)?$ +``` + +Avoid broad patterns such as: + +```text +.* +``` + +A broad regex often hides a catch-all rule and is harder to review. IncidentRelay validates regex safety and limits evaluated input size, but a precise expression is still easier to operate. + +## Actions + +Actions run in the order displayed inside a matching rule. An earlier action can provide data for a later action. + +### Change event fields + +| Action | Purpose | Example value | +| --- | --- | --- | +| `set_title` | Replace the alert title. | `Database unavailable on {{ labels.instance }}` | +| `set_message` | Replace the event message. | `Original alert: {{ event.title }}` | +| `set_description` | Replace the longer description. | `Environment: {{ labels.environment }}` | +| `set_severity` | Set normalized severity. | `critical` | +| `set_priority` | Set incident priority directly. | `P1` | +| `set_dedup_key` | Decide which repeated events update the same child alert. | `{{ labels.alertname }}:{{ labels.instance }}` | +| `set_group_key` | Decide which child alerts belong to the same alert group. | `{{ labels.alertname }}:{{ labels.environment }}` | +| `set_event_action` | Force trigger or resolve semantics. | `trigger` or `resolve` | + +Use stable values for deduplication and grouping. Do not include timestamps or other values that change on every request unless you intentionally want a new alert every time. + +### Labels and custom fields + +| Action | Purpose | +| --- | --- | +| `set_label` | Add or replace one label. | +| `remove_label` | Remove one label. | +| `set_custom_field` | Add or replace one value in `event.custom_details`. | +| `remove_custom_field` | Remove one custom detail. | +| `add_note` | Add an orchestration note to the result trace. | + +Example: + +```text +set_label +name: owner +value: platform +``` + +### Select ownership and policies + +| Action | Result | +| --- | --- | +| `set_team` | Select a team in the current group. | +| `set_route` | Select a route in the current group. The route source must match the event source. | +| `set_service` | Select an enabled service in the current group. | +| `set_escalation_policy` | Select an enabled escalation policy belonging to the selected team. | +| `set_notification_policy` | Select an enabled notification policy belonging to the selected team. | +| `set_priority_policy` | Select an enabled priority policy belonging to the selected team. | + +The Builder loads allowed objects from the current group. Runtime validates that the selected route, team, service and policies form a consistent combination. + +Examples of invalid combinations: + +- route belongs to another group; +- route source is `sentry` but the event source is `alertmanager`; +- selected service belongs to a different team than the selected route; +- selected policy belongs to another team; +- selected object was disabled or deleted after publication. + +In hybrid mode such a candidate is rejected and legacy processing may continue. In orchestration compatibility mode the failure can block processing. + +### Set grouping + +`set_grouping` can define: + +- `group_key`; +- `dedup_key`; +- `window_seconds`. + +Example: + +```text +group_key: {{ labels.alertname }}:{{ labels.environment }} +dedup_key: {{ labels.alertname }}:{{ labels.instance }} +window_seconds: 900 +``` + +This creates one incident per alert name and environment, while preserving one child alert per instance for a 15-minute grouping window. + +Read [Alerts and alert groups](alerts.md) before changing grouping in production. + +### Suppress, pause and drop + +These three actions have very different consequences. + +| Action | Alert created? | Visible in IncidentRelay? | Notifications/escalation? | Typical use | +| --- | --- | --- | --- | --- | +| `suppress` | Yes | Yes | Suppressed | Keep a known event visible for investigation without paging. | +| `pause` | Not immediately | Pending until activation | Not until activation | Wait for a transient issue to persist. | +| `drop` | No | No alert or group | No | Ignore events that have no operational value. | + +#### `suppress` + +The alert and alert group are created or updated. They are marked as orchestration-suppressed, scheduled notifications are cleared, and escalation is not scheduled while suppression applies. + +Use it when operators may still need to search, review or correlate the event. + +Example: + +```text +IF labels.environment equals development +AND event.severity equals warning +THEN suppress reason "Development warning" +``` + +#### `pause` + +The event is stored as pending and is activated only after the configured number of seconds. + +If a matching resolve event arrives before activation, the pending event is resolved without creating an alert. + +`retrigger` controls repeated firing events: + +- `preserve` keeps the original activation time; +- `reset` starts the delay again after every repeated firing event. + +Example: + +```text +pause 300 seconds +retrigger preserve +reason "Wait five minutes for transient recovery" +``` + +Use `preserve` when the alert should appear after five minutes from the first failure. Use `reset` when the alert should appear only after five quiet-free minutes from the most recent failure signal. + +#### `drop` + +Processing stops and no alert or alert group is created. + +Use it only for events that must never be retained as IncidentRelay alerts, such as an integration test heartbeat that has no incident value. + +!!! danger "Dropped events are intentionally absent" + A dropped event cannot be found in the Alerts page because no alert is created. Review execution traces and replay results before enabling drop rules. + +### Queue a webhook + +`enqueue_webhook` selects one reusable webhook action. It does not perform the HTTP request inside the alert request. IncidentRelay queues an asynchronous execution after the orchestration result is successfully applied. + +Simulation and shadow evaluation do not send webhooks. + +## After match: processing mode + +Processing mode controls what happens to later rules after the current rule matches. + +| Mode | Behavior | +| --- | --- | +| `continue` | Apply actions and continue with the next sibling rule. | +| `stop` | Apply actions and stop this orchestration. The normal alert lifecycle still continues. | +| `evaluate_children` | Evaluate child rules, then stop sibling processing. | +| `children_then_continue` | Evaluate child rules, then continue with later sibling rules. | + +The visual Builder currently focuses on top-level rules and nested condition groups. Advanced rule trees with child rules can be managed through JSON or API when needed. + +Use `stop` for mutually exclusive routing decisions: + +```text +Rule 1: production database → Database team → stop +Rule 2: production frontend → Frontend team → stop +Rule 3: catch-all → Platform intake → stop +``` + +Use `continue` when several independent enrichments should all apply: + +```text +Rule 1 adds environment label +Rule 2 normalizes severity +Rule 3 selects priority +``` + +## Action failure behavior + +Every action has an `on_failure` setting: + +| Value | Behavior | +| --- | --- | +| `continue` | Record the failure and continue to the next action. | +| `stop_rule` | Stop the current rule's action sequence. | +| `stop_orchestration` | Stop the complete orchestration evaluation. | + +Examples of runtime failures include a required template field being absent or an invalid value being produced. + +For routing and disposition actions, prefer `stop_rule` or `stop_orchestration` when continuing would create a misleading partial result. For optional enrichment, `continue` is often acceptable. + +## Templates + +Text actions can use safe templates surrounded by `{{` and `}}`. + +Example: + +```text +Database alert on {{ labels.instance }} in {{ labels.environment }} +``` + +Templates can reference the same safe field roots used by conditions. + +Supported filters: + +| Filter | Example | Result | +| --- | --- | --- | +| `lower` | `{{ labels.service | lower }}` | Lowercase text. | +| `upper` | `{{ labels.environment | upper }}` | Uppercase text. | +| `trim` | `{{ event.title | trim }}` | Remove leading and trailing whitespace. | +| `default` | `{{ labels.owner | default("unknown") }}` | Use a fallback when absent, null or empty. | +| `replace` | `{{ labels.service | replace("_", "-") }}` | Replace text. | +| `truncate` | `{{ event.message | truncate(120) }}` | Limit output length. | + +Filters can be chained: + +```text +{{ labels.service | default("unknown") | trim | lower }} +``` + +Templates are intentionally restricted. They cannot execute Python, call methods, access object attributes or run arbitrary code. + +### Template missing fields + +This template fails when `labels.cluster` is absent: + +```text +Cluster {{ labels.cluster }} is unavailable +``` + +This version is safer: + +```text +Cluster {{ labels.cluster | default("unknown") }} is unavailable +``` + +Use the simulator to test events with optional labels removed. + +## Practical examples + +The following examples show complete operational intentions. IDs for teams, services and policies are selected from the Builder and therefore are not hard-coded in the descriptions. + +### Example 1: route RabbitMQ alerts by labels + +Goal: one shared Alertmanager integration receives many alerts, but RabbitMQ events should go to the messaging team and service. + +Conditions: + +```text +ALL +├── labels.job equals RabbitMQ +└── labels.rabbitmq regex ^rabbitmq(-[a-z0-9-]+)?$ +``` + +Actions: + +```text +set_team → Messaging team +set_service → RabbitMQ service +set_label → name=component, value=rabbitmq +set_grouping → group_key={{ labels.alertname }}:{{ labels.rabbitmq }} +stop +``` + +Why use regex here: the `rabbitmq` label can contain names such as `rabbitmq-cloud`, `rabbitmq-production` or `rabbitmq-eu1`, while the prefix remains stable. + +### Example 2: normalize severity names + +Goal: one source sends `disaster`, while IncidentRelay rules expect `critical`. + +Rule: + +```text +IF event.severity equals disaster +THEN set_severity critical +AFTER continue +``` + +A later rule can now consistently check `event.severity equals critical`. + +### Example 3: build a useful title + +Goal: replace a generic alert title with service and instance information. + +Condition: + +```text +labels.service exists +``` + +Action: + +```text +set_title +{{ labels.service | upper }}: {{ event.title | trim }} on {{ labels.instance | default("unknown instance") }} +``` + +Result: + +```text +PAYMENTS: High error rate on payments-api-3 +``` + +### Example 4: suppress development warnings + +Goal: keep development warnings visible without notifying on-call users. + +Conditions: + +```text +ALL +├── labels.environment equals development +└── event.severity in ["info", "warning"] +``` + +Actions: + +```text +set_label name=suppression_source value=orchestration +suppress reason="Non-production informational event" +stop +``` + +Use `suppress`, not `drop`, because developers may still need to search the event later. + +### Example 5: delay transient failures + +Goal: create an alert only when an endpoint stays unhealthy for five minutes. + +Conditions: + +```text +ALL +├── labels.alertname equals EndpointUnavailable +└── labels.environment equals production +``` + +Action: + +```text +pause +seconds: 300 +retrigger: preserve +reason: "Wait for transient endpoint recovery" +``` + +A resolve event with the same source and dedup key before activation prevents alert creation. + +### Example 6: drop an integration test event + +Goal: a monitoring system sends a known test signal that should not become an alert. + +Conditions: + +```text +ALL +├── labels.alertname equals IncidentRelayIntegrationTest +├── labels.environment equals test +└── labels.intent equals connectivity-check +``` + +Action: + +```text +drop reason="Expected integration connectivity test" +``` + +Use several exact conditions. Do not use only `event.title contains test`, because real incidents may also contain that word. + +### Example 7: select policies for customer-impacting incidents + +Goal: customer-impacting critical events should use the fastest escalation and a dedicated notification policy. + +Conditions: + +```text +ALL +├── labels.customer_impacting is_true +└── event.severity equals critical +``` + +Actions: + +```text +set_priority P1 +set_escalation_policy Customer Critical escalation +set_notification_policy Customer Incident notifications +set_label name=customer_impacting, value=true +stop +``` + +The selected policies must belong to the selected team. + +### Example 8: group many hosts into one incident + +Goal: twenty hosts report the same cluster partition, and operators should receive one incident containing twenty child alerts. + +Actions: + +```text +set_grouping +group_key: {{ labels.alertname }}:{{ labels.cluster }} +dedup_key: {{ labels.alertname }}:{{ labels.instance }} +window_seconds: 1800 +``` + +The shared group key combines the host alerts. The per-instance dedup key ensures repeated signals update the correct child alert. + +### Example 9: queue a diagnostic webhook + +Goal: when a critical database event arrives, asynchronously request diagnostics from an internal automation service. + +1. A global administrator creates a webhook action named `Collect database diagnostics`. +2. The orchestration rule matches the database service and critical severity. +3. The rule selects `enqueue_webhook → Collect database diagnostics`. +4. IncidentRelay applies the orchestration and creates the alert. +5. The scheduler delivers the webhook asynchronously with retries. + +A body template can include: + +```json +{ + "service": "{{ labels.service }}", + "instance": "{{ labels.instance }}", + "alert": "{{ event.title }}", + "severity": "{{ event.severity }}" +} +``` + +The webhook is not sent during simulation or shadow mode. + +## Simulator + +The Simulator evaluates the current draft in isolation. It does not: + +- create or update alerts; +- change runtime mode; +- publish a version; +- send notifications; +- execute outbound webhooks; +- modify production data. + +### Normalized event input + +Choose **Normalized event** when you already know the fields used by your rules. + +Minimum useful example: + +```json +{ + "source": "webhook", + "title": "Example alert", + "severity": "warning", + "status": "firing", + "dedup_key": "example-1", + "labels": { + "environment": "staging" + } +} +``` + +### Raw integration payload + +Choose an integration source to pass the payload through the same registered normalizer used by production ingestion. + +Supported sources in this release are: + +```text +alertmanager +aws_sns +datadog +grafana +librenms +rmon +sentry +webhook +zabbix +``` + +This is useful when you do not know exactly how a raw payload becomes normalized labels and event fields. + +### Compare with active version + +Enable **Compare with active version** when an orchestration already has a published version. + +The result includes a draft-versus-active difference showing fields and decisions that would change after publication. + +Review changes to: + +- route, team and service; +- title, severity and priority; +- grouping and deduplication; +- policies; +- disposition; +- queued webhooks. + +### Reading a simulation result + +Important result areas include: + +| Area | What to check | +| --- | --- | +| Initial normalized event | Did the integration normalize the payload as expected? | +| Matched rules | Did only the intended rules match? | +| Condition trace | Which field and value caused a match or mismatch? | +| Action trace | What changed before and after each action? | +| Final context | What event, labels, routing and policies remain after all rules? | +| Disposition | Will the event process, suppress, pause or drop? | +| Active/draft diff | What changes compared with production? | + +A simulation that returns successfully can still represent an incorrect business decision. Verify the selected entities and final values, not only the absence of errors. + +## Replay + +Replay evaluates stored alerts or previous orchestration executions against a selected version. It does not apply changes to production. + +Replay is useful when: + +- changing a broad rule; +- introducing a drop or suppress condition; +- changing grouping; +- replacing route selection; +- checking how a draft behaves against real historical data. + +Replay reports counts, failures, dispositions and changes from the active version. It also warns when the draft would drop a high percentage of replayed events. + +The current web page focuses on single-event simulation. Replay is available through the orchestration API and can be added to operational review workflows. + +## Validation, publication and versions + +### Save draft + +**Save draft** stores the editable definition and records the current user as the last editor. + +Saving does not change the published production version. + +### Validate + +**Validate** saves the current builder state and performs static and entity checks. + +Validation does not prove that the business logic is correct. Simulation, shadow mode and review are still required. + +### Publish + +**Publish** creates an immutable version and makes it the active version for the orchestration definition. + +Publication does not automatically change runtime mode. A disabled orchestration remains disabled after publication. + +Every meaningful publication should include a comment through API-managed workflows. The UI version history exposes stored comments where present. + +### Immutable history + +Published versions are not edited in place. This provides: + +- an exact definition hash; +- reliable execution-to-version linkage; +- clear authorship; +- safe comparison; +- reproducible rollback. + +### Rollback + +Rollback does not rewrite history. IncidentRelay copies the selected historical definition into a new version and publishes that copy. + +After rollback: + +- old versions remain unchanged; +- the new version has its own number and publication author; +- future executions reference the new version. + +## Shadow mode and executions + +Shadow mode is the safest way to observe a published definition against real traffic. + +In shadow mode: + +- the published rules are evaluated; +- execution records and traces are stored; +- candidate decisions can be compared with actual lifecycle results; +- production event behavior is unchanged; +- webhook actions are not delivered. + +### Execution list + +The **Executions** tab shows: + +- integration source; +- final disposition; +- number of matched rules; +- evaluation duration; +- creation time; +- a trace button. + +The trace contains redacted initial context, condition results, actions, before/after values, final context, selected entities and errors. + +### What to review before activation + +Review a representative sample from each important source and event type. + +Look for: + +- unexpected catch-all matches; +- events routed to the wrong team or service; +- route/source mismatches; +- missing optional template fields; +- too many suppressed, paused or dropped events; +- unstable dedup or group keys; +- large evaluation durations; +- rules that never match; +- later rules undoing earlier actions. + +### Shadow metrics + +Shadow metrics summarize candidate differences such as routing, disposition and event mutations. Use them to find systematic changes, then open individual execution traces to understand why they occurred. + +Metrics are a signal, not an approval mechanism. A small difference count may still contain one critical misroute. + +## Webhook actions + +Webhook actions are reusable group-owned outbound HTTP definitions. They are separate from rule definitions so secrets and delivery settings are managed centrally. + +Only a global administrator can create, edit or delete them. + +### Create a webhook action + +Open **Webhook Actions** and click **Add webhook**. + +Configure: + +| Field | Meaning | +| --- | --- | +| Name | Human-readable action name shown in rule selectors. | +| Description | What the remote system does and when the action should be used. | +| URL | Destination URL. HTTPS is required unless HTTP is explicitly enabled by the administrator. | +| Method | GET, POST, PUT, PATCH or DELETE. | +| Secret headers JSON | Authentication and other headers. Values are encrypted and never returned by the API. | +| Body template | Optional safe template. When empty, IncidentRelay sends event and result JSON. | +| Timeout | Maximum request duration. | +| Retries | Number of delivery retries. | +| Private network policy | Whether private-address targets are denied or must be in the configured allowlist. | +| Enabled | Disabled actions are not queued. | + +Example secret headers: + +```json +{ + "Authorization": "Bearer secret-token", + "X-Source": "IncidentRelay" +} +``` + +When editing an existing action, the UI intentionally displays `{}` instead of decrypting stored headers. Leaving headers empty preserves the existing encrypted headers. Supplying a new non-empty object replaces them. + +### Security behavior + +Webhook delivery includes protections against server-side request forgery and secret exposure: + +- URLs must use HTTP or HTTPS and cannot contain embedded credentials; +- HTTPS is required by default; +- private and special network addresses are denied unless explicitly allowlisted; +- redirects are limited and revalidated; +- dangerous hop-by-hop headers are rejected; +- secret headers are encrypted; +- API responses never return header values; +- logged errors and response excerpts are redacted and size-limited; +- requests receive an idempotency key; +- per-group concurrency and rate limits apply. + +### Scheduler requirement + +Outbound webhook requests are asynchronous. The IncidentRelay scheduler process must be running for pending automation executions and retries to be delivered. + +## Advanced JSON view + +The Builder covers common condition and action types. **JSON view** exposes the complete deterministic definition and is useful for: + +- copying a definition for review; +- applying bulk changes; +- advanced variable extraction; +- child rule trees; +- API-generated configurations. + +Always click **Apply JSON**, then **Save draft**, and validate after editing JSON. + +A complete example: + +```json +{ + "schema_version": 1, + "rules": [ + { + "name": "Critical production database", + "description": "Route and enrich critical database events.", + "enabled": true, + "condition_tree": { + "all": [ + { + "field": "labels.environment", + "operator": "equals", + "value": "production" + }, + { + "field": "labels.component", + "operator": "equals", + "value": "database" + }, + { + "field": "event.severity", + "operator": "in", + "value": ["critical", "fatal"] + } + ] + }, + "actions": [ + { + "type": "set_title", + "template": "DB: {{ event.title | trim }} on {{ labels.instance | default(\"unknown\") }}" + }, + { + "type": "set_priority", + "value": "P1" + }, + { + "type": "set_label", + "name": "orchestrated", + "value": "true" + }, + { + "type": "set_grouping", + "group_key": "{{ labels.alertname }}:{{ labels.cluster | default(\"default\") }}", + "dedup_key": "{{ labels.alertname }}:{{ labels.instance }}", + "window_seconds": 1800 + } + ], + "processing_mode": "stop", + "children": [] + } + ] +} +``` + +Team, route, service and policy actions require numeric IDs. Prefer selecting them in the Builder so the catalog supplies group-valid objects. + +### Advanced variable actions + +The engine also supports deterministic variable extraction actions through JSON and API: + +```text +extract_regex +copy_field +copy_to_variable +json_path +split +set_variable +static +lowercase +uppercase +trim +``` + +Variables are stored under `variables.` for later conditions and templates. + +Example: + +```json +{ + "type": "extract_regex", + "source": "labels.instance", + "pattern": "^(?P[a-z0-9-]+)-[0-9]+$" +} +``` + +After a successful extraction, a later action can use: + +```text +{{ variables.service }} +``` + +Use advanced extraction only when the normalized integration fields cannot already supply the required value. + +## Troubleshooting + +### The orchestration never runs + +Check: + +1. A version is published. +2. Runtime mode is `shadow` or `active`. +3. The event belongs to the same group. +4. A global orchestration has the expected group context. +5. A service orchestration is attached to the service actually selected for the event. +6. `active + legacy` is not being used when production application is expected. +7. The orchestration and selected service are enabled. + +### A rule never matches + +Use Simulator and inspect the condition trace. + +Common causes: + +- using `event.environment` when the value is in `labels.environment`; +- wrong capitalization; +- comparing a string to a JSON array incorrectly; +- raw payload path differs from the normalized event; +- optional label is absent; +- regex anchors or escaping are incorrect; +- an earlier rule changed the field; +- the rule is disabled. + +### `in` or `not_in` does not validate + +Enter a JSON collection: + +```json +["critical", "fatal"] +``` + +Do not enter only: + +```text +critical +``` + +### A template fails + +A referenced field is probably absent or contains a non-scalar object. + +Use `default` for optional fields: + +```text +{{ labels.cluster | default("unknown") }} +``` + +Test with the optional label removed. + +### A selected route is rejected + +Confirm that: + +- the route is enabled; +- the route belongs to the orchestration group; +- the route source equals the event source; +- the route team is active; +- selected service and policies belong to the same team where required. + +### The event is visible but nobody was notified + +Check whether the execution disposition is `suppress`, whether a silence or maintenance window matched, and whether a notification policy found a target. + +An orchestration-suppressed event is intentionally stored while notification and escalation are disabled. + +### The event is missing from Alerts + +Check the execution disposition: + +- `drop` means no alert was created; +- `pause` means the event is pending and may activate later; +- a resolve may have arrived before a paused event activated. + +### A paused event never activates + +Check: + +- the scheduler process is running; +- the pending event has not already resolved; +- activation time has passed; +- repeated events with `retrigger=reset` are not continually extending the delay; +- scheduler and pending-event logs for safe error types. + +### A webhook is not sent + +Check: + +- runtime mode is `active`, not shadow; +- the orchestration execution was applied successfully; +- the webhook action is enabled; +- the scheduler is running; +- the URL uses HTTPS unless HTTP is explicitly enabled; +- private-address targets are allowed by configuration; +- rate or concurrency limits are not delaying delivery; +- the webhook execution status and safe error message. + +### Publish is unavailable + +The user must be a group editor or global administrator for the selected group. Confirm active group membership and the group role. + +### Changes appear under another author + +The version list distinguishes: + +- creator of the version; +- last user who changed its draft; +- user who published it. + +When one user edits and another publishes, **Changed by** and **Published by** are expected to differ. + +## Operational design recommendations + +### Give rules business names + +Good: + +```text +Critical production database routing +Suppress development backup warnings +Delay transient endpoint failures +``` + +Less useful: + +```text +Rule 1 +New rule +Test +``` + +### Explain why, not only what + +Use descriptions and publication comments to record the operational reason. + +Example: + +```text +Suppress warning-level backup events in development because they are reviewed in the daily report and must not page on-call. +``` + +### Prefer explicit conditions + +Prefer: + +```text +labels.environment equals production +AND labels.component equals database +AND event.severity equals critical +``` + +instead of: + +```text +event.title contains DB +``` + +Structured labels are more stable than human-readable text. + +### Keep mutually exclusive routing rules ordered + +Put the most specific rules first and stop after a routing match. + +```text +1. Payments production critical +2. Payments other +3. Other production +4. Catch-all +``` + +### Separate normalization from final decisions + +A clear definition often follows this order: + +```text +1. Normalize labels and severity +2. Select ownership +3. Select priority and policies +4. Set grouping +5. Apply suppress/pause/drop decisions +6. Queue automation +``` + +### Avoid hidden coupling + +When a later rule depends on a field changed earlier, document it in both rule descriptions. + +### Test both directions + +For each important rule, test: + +- a matching event; +- a near match that must not match; +- missing optional fields; +- resolved status; +- another environment; +- another source; +- existing active version comparison. + +### Review destructive decisions separately + +Treat these as high risk: + +- `drop`; +- broad `suppress`; +- long `pause`; +- route replacement; +- `set_event_action resolve`; +- grouping-key changes; +- orchestration compatibility mode. + +Use replay and shadow observation before activation. + +## Frequently asked questions + +### Does saving a draft affect production? + +No. Production uses the published active version, subject to runtime mode. + +### Does publishing immediately apply rules? + +Publishing updates the active version, but a runtime mode of `disabled` still prevents execution. In shadow mode the version is evaluated without changing production behavior. + +### Can a group editor publish? + +Yes. A group editor can edit, validate, simulate, replay, publish and rollback orchestrations in that group. + +### Can a group editor create webhook actions? + +No. Reusable webhook-action management remains restricted to global administrators. + +### Does `stop` mean the alert is discarded? + +No. It stops later orchestration rules. The event continues into the normal lifecycle with the decisions already made. + +### What is the difference between `suppress` and a silence? + +Both can prevent notifications. Orchestration suppression is a rule-engine decision recorded on the alert and can depend on mutated fields or ordered rule logic. A silence is a dedicated alert-suppression configuration and is usually clearer for temporary or independently managed suppression windows. + +### What is the difference between `pause` and an Alertmanager `for` duration? + +An upstream `for` duration prevents Alertmanager from sending the alert until the expression remains true. Orchestration pause acts after IncidentRelay receives the event, stores a pending event and can cancel it when a matching resolve arrives before activation. + +### Can orchestration call scripts? + +No. Arbitrary Python, shell, SSH and container execution are not supported. Use a secured asynchronous webhook to a purpose-built automation service. + +### Can I use raw payload fields? + +Yes, through `raw.`, but normalized `event` and `labels` fields are usually more portable across integrations and easier to test. + +### Can I undo a publication? + +Yes. Use Versions and rollback. IncidentRelay publishes a new copy of the selected historical definition instead of changing old history. + +### Where can I see why an event was handled a certain way? + +Open the orchestration's **Executions** tab and view the trace. The alert Explain trace also includes orchestration runtime information when available. + +## Related documentation + +- [Event Orchestration API](../api/event-orchestration.md) +- [Event Orchestration architecture](../architecture/event-orchestration-v1.md) +- [Alerts and alert groups](alerts.md) +- [Groups and RBAC](../concepts/groups-and-rbac.md) +- [Services](../concepts/services.md) +- [Escalation Policies](../concepts/escalation-policies.md) +- [Notification channels](../integrations/channels.md) +- [Administration logging](../administration/logging.md) diff --git a/docs/usage/index.md b/docs/usage/index.md index a5c2f90..557d8c4 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -9,6 +9,7 @@ This section describes user-owned settings and daily personal workflows: - [Profile and Personal API Tokens](profile-and-tokens.md) - [Browser Push](browser-push.md) +- [Event Orchestration](event-orchestration.md) Operational workflows now live in focused sections: diff --git a/tests/orchestration/test_orchestration_documentation.py b/tests/orchestration/test_orchestration_documentation.py new file mode 100644 index 0000000..253e685 --- /dev/null +++ b/tests/orchestration/test_orchestration_documentation.py @@ -0,0 +1,49 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +GUIDE = ROOT / "docs" / "usage" / "event-orchestration.md" + + +def test_event_orchestration_user_guide_covers_safe_user_workflow(): + content = GUIDE.read_text(encoding="utf-8") + + required_sections = ( + "# Event Orchestration user guide", + "## A safe first rollout", + "## Create your first orchestration", + "## Conditions", + "## Actions", + "## Practical examples", + "## Simulator", + "## Shadow mode and executions", + "## Webhook actions", + "## Troubleshooting", + "## Frequently asked questions", + ) + for section in required_sections: + assert section in content + + for safety_term in ( + "suppress", + "pause", + "drop", + "shadow", + "hybrid", + "rollback", + "Changed by", + "Published by", + ): + assert safety_term in content + + +def test_event_orchestration_user_guide_is_linked_from_docs_navigation(): + navigation = (ROOT / "docs" / "mkdocs.yml").read_text(encoding="utf-8") + usage_index = (ROOT / "docs" / "usage" / "index.md").read_text(encoding="utf-8") + api_guide = (ROOT / "docs" / "api" / "event-orchestration.md").read_text( + encoding="utf-8" + ) + + assert "Event Orchestration: usage/event-orchestration.md" in navigation + assert "[Event Orchestration](event-orchestration.md)" in usage_index + assert "[Event Orchestration user guide](../usage/event-orchestration.md)" in api_guide diff --git a/tests/orchestration/test_orchestration_openapi.py b/tests/orchestration/test_orchestration_openapi.py new file mode 100644 index 0000000..7b5febf --- /dev/null +++ b/tests/orchestration/test_orchestration_openapi.py @@ -0,0 +1,106 @@ +from app.api.openapi.spec import build_openapi_spec +from app.services.orchestration.actions import SUPPORTED_ACTION_TYPES +from app.services.orchestration.conditions import SUPPORTED_OPERATORS + + +EXPECTED_PATHS = { + "/api/event-orchestrations", + "/api/event-orchestrations/catalog", + "/api/event-orchestrations/{orchestration_id}", + "/api/event-orchestrations/{orchestration_id}/draft", + "/api/event-orchestrations/{orchestration_id}/validate", + "/api/event-orchestrations/{orchestration_id}/publish", + "/api/event-orchestrations/{orchestration_id}/rollback", + "/api/event-orchestrations/{orchestration_id}/runtime", + "/api/event-orchestrations/{orchestration_id}/versions", + "/api/event-orchestrations/{orchestration_id}/versions/{version_id}", + "/api/event-orchestrations/{orchestration_id}/simulate", + "/api/event-orchestrations/{orchestration_id}/replay", + "/api/event-orchestrations/{orchestration_id}/executions", + "/api/event-orchestrations/{orchestration_id}/shadow-metrics", + "/api/orchestration-webhook-actions", + "/api/orchestration-webhook-actions/{action_id}", + "/api/orchestration-webhook-actions/{action_id}/executions", +} + + +def test_openapi_documents_all_event_orchestration_control_plane_paths(): + spec = build_openapi_spec() + + assert EXPECTED_PATHS <= set(spec["paths"]) + tags = {item["name"] for item in spec["tags"]} + assert "event-orchestrations" in tags + assert "orchestration-webhook-actions" in tags + + operation_ids = [] + for path in EXPECTED_PATHS: + for operation in spec["paths"][path].values(): + operation_ids.append(operation["operationId"]) + assert operation["security"] == [{"bearerAuth": []}] + assert len(operation_ids) == len(set(operation_ids)) + + +def test_openapi_condition_tree_is_recursive_and_lists_runtime_operators(): + schemas = build_openapi_spec()["components"]["schemas"] + condition = schemas["OrchestrationCondition"] + group = schemas["OrchestrationConditionGroup"] + tree = schemas["OrchestrationConditionTree"] + + assert set(condition["properties"]["operator"]["enum"]) == set( + SUPPORTED_OPERATORS + ) + assert tree["oneOf"] == [ + {"$ref": "#/components/schemas/OrchestrationCondition"}, + {"$ref": "#/components/schemas/OrchestrationConditionGroup"}, + ] + for logical_key in ("all", "any", "none"): + assert group["properties"][logical_key]["items"] == { + "$ref": "#/components/schemas/OrchestrationConditionTree" + } + + +def test_openapi_action_schema_tracks_safe_supported_actions(): + action = build_openapi_spec()["components"]["schemas"][ + "OrchestrationAction" + ] + + assert set(action["properties"]["type"]["enum"]) == set( + SUPPORTED_ACTION_TYPES + ) + assert "enqueue_webhook" in action["properties"]["type"]["enum"] + assert "shell" not in action["properties"]["type"]["enum"] + assert "python" not in action["properties"]["type"]["enum"] + + +def test_openapi_versions_include_editor_and_publisher_metadata(): + version = build_openapi_spec()["components"]["schemas"][ + "OrchestrationVersion" + ] + + for field in ( + "created_by_id", + "created_by", + "updated_by_id", + "updated_by", + "published_by_id", + "published_by", + ): + assert field in version["properties"] + + publish = build_openapi_spec()["paths"][ + "/api/event-orchestrations/{orchestration_id}/publish" + ]["post"] + assert "Group editors" in publish["description"] + + +def test_openapi_webhook_headers_are_write_only_and_never_in_response(): + spec = build_openapi_spec() + create_schema = spec["paths"]["/api/orchestration-webhook-actions"][ + "post" + ]["requestBody"]["content"]["application/json"]["schema"] + response_schema = spec["components"]["schemas"][ + "OrchestrationWebhookAction" + ] + + assert create_schema["properties"]["headers"]["writeOnly"] is True + assert "headers" not in response_schema["properties"] From 0a3e4864b6a6fa7c165c6ae6b145f275f694f398 Mon Sep 17 00:00:00 2001 From: Pavel Loginov Date: Tue, 28 Jul 2026 08:50:08 +0300 Subject: [PATCH 14/34] Add Uptime Kuma integration, tests, and documentation - Implements Uptime Kuma normalizer for state mapping, severity, and tag handling. - Adds comprehensive test coverage for normalizer, route behavior, lifecycle, and payload validation. - Includes full integration guide and troubleshooting in the documentation. - Registers UI updates and API paths for the new integration. --- app/api/openapi/endpoints/integrations.py | 168 +++++- app/api/openapi/endpoints/routes.py | 2 +- app/api/schemas/integrations.py | 38 ++ app/api/schemas/routes.py | 2 +- .../integrations/normalizers/aws_sns.py | 22 +- .../integrations/normalizers/common.py | 79 ++- .../integrations/normalizers/datadog.py | 110 ++-- .../integrations/normalizers/registry.py | 2 + .../integrations/normalizers/uptime_kuma.py | 302 +++++++++++ .../integrations/normalizers/webhook.py | 40 +- app/static/i18n/de/routes.json | 4 + app/static/i18n/en/routes.json | 6 +- app/static/i18n/ru/routes.json | 6 +- app/static/js/pages/routes.js | 30 +- app/templates/pages/routes.html | 4 + app/views/integrations_view.py | 27 + docs/integrations/index.md | 1 + docs/integrations/uptime-kuma.md | 482 ++++++++++++++++++ docs/mkdocs.yml | 1 + tests/integrations/test_normalizer_common.py | 57 +++ .../integrations/test_normalizer_registry.py | 1 + .../test_uptime_kuma_integration.py | 306 +++++++++++ 22 files changed, 1557 insertions(+), 133 deletions(-) create mode 100644 app/services/integrations/normalizers/uptime_kuma.py create mode 100644 docs/integrations/uptime-kuma.md create mode 100644 tests/integrations/test_normalizer_common.py create mode 100644 tests/integrations/test_uptime_kuma_integration.py diff --git a/app/api/openapi/endpoints/integrations.py b/app/api/openapi/endpoints/integrations.py index 93e3164..a948766 100644 --- a/app/api/openapi/endpoints/integrations.py +++ b/app/api/openapi/endpoints/integrations.py @@ -791,9 +791,10 @@ def tags(): "name": "integrations", "description": ( "Incoming alert endpoints for Alertmanager, Grafana, RMON, " - "Amazon SNS and CloudWatch, Zabbix, LibreNMS, generic webhooks " - "and Sentry. Alertmanager, Grafana, RMON, Zabbix, LibreNMS " - "and generic webhooks use route intake tokens. Sentry uses a " + "Amazon SNS and CloudWatch, Zabbix, LibreNMS, Uptime Kuma, " + "generic webhooks and Sentry. Alertmanager, Grafana, RMON, " + "Zabbix, LibreNMS, Uptime Kuma and generic webhooks use route " + "intake tokens. Sentry uses a " "route-specific webhook signature. Amazon SNS requests are " "verified using the SNS signature and the exact Topic ARN " "configured for the route." @@ -2164,6 +2165,143 @@ def paths(): } + uptime_kuma_body = { + "type": "object", + "description": ( + "Standard Uptime Kuma Webhook notification payload. Uptime Kuma " + "sends monitor, heartbeat and msg fields when the default JSON " + "request body is used." + ), + "additionalProperties": True, + "properties": { + "heartbeat": { + "type": "object", + "nullable": True, + "additionalProperties": True, + "properties": { + "monitorID": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + "description": "Stable Uptime Kuma monitor id.", + }, + "status": { + "oneOf": [ + {"type": "integer", "enum": [0, 1, 2, 3]}, + {"type": "string"}, + ], + "description": ( + "Uptime Kuma status: 0 DOWN, 1 UP, 2 PENDING, " + "3 MAINTENANCE." + ), + }, + "msg": { + "type": "string", + "nullable": True, + "description": "Monitor check result message.", + }, + "ping": { + "type": "number", + "nullable": True, + "description": "Observed latency in milliseconds.", + }, + "time": { + "type": "string", + "nullable": True, + }, + "localDateTime": { + "type": "string", + "nullable": True, + }, + }, + }, + "monitor": { + "type": "object", + "nullable": True, + "additionalProperties": True, + "properties": { + "id": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + }, + "name": {"type": "string", "nullable": True}, + "type": {"type": "string", "nullable": True}, + "url": {"type": "string", "nullable": True}, + "hostname": {"type": "string", "nullable": True}, + "port": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ], + "nullable": True, + }, + "tags": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": True, + "properties": { + "name": {"type": "string"}, + "value": { + "type": "string", + "nullable": True, + }, + }, + }, + }, + }, + }, + "msg": { + "type": "string", + "nullable": True, + "description": "Human-readable Uptime Kuma notification text.", + }, + "severity": { + "type": "string", + "nullable": True, + "description": ( + "Optional IncidentRelay severity override for custom bodies." + ), + }, + "labels": { + "type": "object", + "additionalProperties": True, + "description": "Optional additional IncidentRelay labels.", + }, + "event_link": { + "type": "string", + "nullable": True, + "description": ( + "Optional link to the Uptime Kuma monitor for custom bodies." + ), + }, + }, + "example": { + "heartbeat": { + "monitorID": 42, + "status": 0, + "msg": "Connection refused", + "ping": None, + "time": "2026-07-27 12:00:00", + }, + "monitor": { + "id": 42, + "name": "Production API", + "type": "http", + "url": "https://api.example.com", + "tags": [ + {"name": "team", "value": "sre"}, + {"name": "severity", "value": "critical"}, + ], + }, + "msg": "Production API is DOWN", + }, + } + + return { "/api/integrations/alertmanager": { "post": { @@ -2298,6 +2436,30 @@ def paths(): "responses": incoming_alert_responses("Zabbix alert accepted."), } }, + + "/api/integrations/uptime-kuma": { + "post": { + "tags": ["integrations"], + "summary": "Receive Uptime Kuma monitor notifications", + "description": ( + "Receives the standard JSON body produced by the Uptime Kuma " + "Webhook notification provider. The route intake token must " + "belong to a route with source=uptime_kuma. DOWN and PENDING " + "events are normalized to firing; UP and MAINTENANCE events " + "are normalized to resolved. Monitor id is used as the stable " + "deduplication key so recovery updates the existing alert." + ), + "operationId": "receiveUptimeKumaNotification", + "security": [{"bearerAuth": []}], + "requestBody": json_body( + "Standard Uptime Kuma Webhook payload.", + uptime_kuma_body, + ), + "responses": incoming_alert_responses( + "Uptime Kuma notification accepted." + ), + }, + }, "/api/integrations/sentry/{route_id}": { "post": { "tags": ["integrations"], diff --git a/app/api/openapi/endpoints/routes.py b/app/api/openapi/endpoints/routes.py index 19a816f..04efbc9 100644 --- a/app/api/openapi/endpoints/routes.py +++ b/app/api/openapi/endpoints/routes.py @@ -61,7 +61,7 @@ def response(description, schema=None): SOURCE_SCHEMA_WITH_SENTRY = { "type": "string", - "enum": ["alertmanager", "aws_sns", "grafana", "rmon", "zabbix", "webhook", "sentry", "librenms", "heartbeat"], + "enum": ["alertmanager", "aws_sns", "datadog", "grafana", "rmon", "zabbix", "webhook", "sentry", "librenms", "uptime_kuma", "heartbeat"], "description": "Incoming alert source type.", } diff --git a/app/api/schemas/integrations.py b/app/api/schemas/integrations.py index 7c803b0..cd9eb95 100644 --- a/app/api/schemas/integrations.py +++ b/app/api/schemas/integrations.py @@ -231,6 +231,44 @@ def validate_not_empty(self): return self +class UptimeKumaWebhookSchema(ApiModel): + """Validate the standard Uptime Kuma Webhook payload.""" + + model_config = ConfigDict(extra="allow") + + heartbeat: Dict[str, Any] | None = None + monitor: Dict[str, Any] | None = None + msg: str | None = None + + # Optional IncidentRelay extensions for custom Uptime Kuma webhook bodies. + title: str | None = None + message: str | None = None + severity: str | None = None + status: str | int | None = None + team: str | None = None + labels: Dict[str, Any] = Field(default_factory=dict) + event_link: str | None = None + monitor_url: str | None = None + monitor_id: str | int | None = None + monitor_type: str | None = None + name: str | None = None + + @model_validator(mode="after") + def validate_not_empty(self): + if not ( + self.heartbeat + or self.monitor + or str(self.msg or "").strip() + or str(self.message or "").strip() + or str(self.title or "").strip() + ): + raise ValueError( + "Uptime Kuma webhook payload must contain heartbeat, monitor or msg" + ) + + return self + + class GenericWebhookSchema(ApiModel): """Validate generic or PagerDuty Events API v2-compatible payloads.""" diff --git a/app/api/schemas/routes.py b/app/api/schemas/routes.py index 8ca42db..045cc8f 100644 --- a/app/api/schemas/routes.py +++ b/app/api/schemas/routes.py @@ -20,7 +20,7 @@ class RouteBaseSchema(ApiModel): team_id: int = Field(ge=1) name: str = Field(min_length=2, max_length=120) - source: str = Field(pattern=r"^(alertmanager|aws_sns|datadog|grafana|zabbix|webhook|sentry|librenms|rmon|heartbeat)$") + source: str = Field(pattern=r"^(alertmanager|aws_sns|datadog|grafana|zabbix|webhook|sentry|librenms|rmon|uptime_kuma|heartbeat)$") rotation_id: int | None = Field(default=None, ge=1) channel_ids: List[int] = Field(default_factory=list) notification_channel_mode: str = Field(default=ROUTE_ONLY, pattern=NOTIFICATION_CHANNEL_MODE_PATTERN) diff --git a/app/services/integrations/normalizers/aws_sns.py b/app/services/integrations/normalizers/aws_sns.py index 7b7fedf..0caa470 100644 --- a/app/services/integrations/normalizers/aws_sns.py +++ b/app/services/integrations/normalizers/aws_sns.py @@ -2,6 +2,7 @@ import re from app.services.integrations.normalizers.common import ( + clean_string, first_non_empty, make_dedup_key, ) @@ -17,15 +18,6 @@ } -def _clean(value): - if value is None: - return None - - value = str(value).strip() - - return value or None - - def _label_key(value): value = str(value or "").strip().lower() value = re.sub(r"[^a-z0-9_]+", "_", value) @@ -34,7 +26,7 @@ def _label_key(value): def _set_label(labels, key, value): key = _label_key(key) - value = _clean(value) + value = clean_string(value) if key and value is not None: labels.setdefault(key, value) @@ -62,7 +54,7 @@ def _message_attribute_value( item.get("StringValue"), ) - return _clean(item) + return clean_string(item) def _sns_labels(envelope): @@ -157,14 +149,14 @@ def _normalize_cloudwatch_alarm( "CloudWatch alarm", ) - alarm_arn = _clean( + alarm_arn = clean_string( message.get("AlarmArn") ) - account_id = _clean( + account_id = clean_string( message.get("AWSAccountId") ) - region = _clean( + region = clean_string( message.get("Region") ) @@ -389,7 +381,7 @@ def _normalize_generic_sns( sort_keys=True, ) - external_id = _clean( + external_id = clean_string( envelope.get("MessageId") ) diff --git a/app/services/integrations/normalizers/common.py b/app/services/integrations/normalizers/common.py index dec0417..fcb8fa8 100644 --- a/app/services/integrations/normalizers/common.py +++ b/app/services/integrations/normalizers/common.py @@ -1,17 +1,29 @@ import hashlib +import json +import re -def normalize_event_link(value): - """Return a clean event/source link value.""" +PRIORITY_SEVERITY = { + "p1": "critical", + "p2": "high", + "p3": "medium", + "p4": "warning", + "p5": "info", +} + + +def clean_string(value): + """Return a stripped string or ``None`` for an empty value.""" if value is None: return None value = str(value).strip() + return value or None - if not value: - return None - return value +def normalize_event_link(value): + """Return a clean event/source link value.""" + return clean_string(value) def first_event_link(*values): @@ -64,3 +76,60 @@ def first_non_empty(*values): return value return None + + +def first_present(*values): + """Return the first present value while preserving ``0`` and ``False``.""" + for value in values: + if value is None: + continue + + if isinstance(value, str) and not value.strip(): + continue + + return value + + return None + + +def canonical_label_key(value): + """Convert an arbitrary label key to lower snake_case.""" + return re.sub( + r"[^a-z0-9]+", + "_", + str(value or "").strip().lower(), + ).strip("_") + + +def normalize_label_value(value): + """Keep scalar label values and serialize complex values deterministically.""" + if value is None: + return None + + if isinstance(value, (str, int, float, bool)): + return value + + try: + return json.dumps(value, ensure_ascii=False, sort_keys=True) + except (TypeError, ValueError): + return str(value) + + +def stable_labels(labels, *, exclude=()): + """Return deterministically ordered labels without volatile keys.""" + excluded = {str(key) for key in exclude} + + return { + str(key): labels[key] + for key in sorted(labels, key=lambda item: str(item)) + if str(key) not in excluded + } + + +def severity_from_priority(value): + """Map a P1-P5 priority to IncidentRelay severity, or return ``None``.""" + priority = clean_string(value) + if not priority: + return None + + return PRIORITY_SEVERITY.get(priority.lower()) diff --git a/app/services/integrations/normalizers/datadog.py b/app/services/integrations/normalizers/datadog.py index 92d9529..924ce38 100644 --- a/app/services/integrations/normalizers/datadog.py +++ b/app/services/integrations/normalizers/datadog.py @@ -1,11 +1,13 @@ -import json -import re - from app.services.integrations.normalizers.common import ( add_event_link_label, + canonical_label_key, + clean_string, first_event_link, first_non_empty, make_dedup_key, + normalize_label_value, + severity_from_priority, + stable_labels, ) from app.services.severity import normalize_severity @@ -20,24 +22,12 @@ "success", } -PRIORITY_SEVERITY = { - "p1": "critical", - "p2": "high", - "p3": "medium", - "p4": "warning", - "p5": "info", -} - - -def _canonical_key(value): - return re.sub(r"[^a-z0-9]+", "_", str(value or "").strip().lower()).strip("_") - def _canonical_payload(payload): result = {} for key, value in (payload or {}).items(): - normalized = _canonical_key(key) + normalized = canonical_label_key(key) if not normalized: continue @@ -49,30 +39,9 @@ def _canonical_payload(payload): return result -def _clean(value): - if value is None: - return None - - value = str(value).strip() - return value or None - - -def _label_value(value): - if value is None: - return None - - if isinstance(value, (str, int, float, bool)): - return value - - try: - return json.dumps(value, ensure_ascii=False, sort_keys=True) - except (TypeError, ValueError): - return str(value) - - def _get(data, *names): for name in names: - value = data.get(_canonical_key(name)) + value = data.get(canonical_label_key(name)) if value is not None: return value @@ -82,7 +51,7 @@ def _get(data, *names): def normalize_datadog_status(transition, alert_type=None): """Convert Datadog transitions to IncidentRelay firing/resolved state.""" - transition = _clean(transition) + transition = clean_string(transition) normalized = str(transition or "").lower().replace("_", " ").replace("-", " ") normalized = " ".join(normalized.split()) @@ -99,13 +68,13 @@ def normalize_datadog_status(transition, alert_type=None): def normalize_datadog_severity(explicit, alert_type=None, priority=None): """Map Datadog severity/type/monitor priority to IncidentRelay severity.""" - explicit = _clean(explicit) + explicit = clean_string(explicit) if explicit: return normalize_severity(explicit) or "info" - priority_key = str(priority or "").strip().lower() - if priority_key in PRIORITY_SEVERITY: - return PRIORITY_SEVERITY[priority_key] + priority_severity = severity_from_priority(priority) + if priority_severity: + return priority_severity return normalize_severity(alert_type) or "info" @@ -120,8 +89,8 @@ def normalize_datadog_tags(value): if isinstance(value, dict): for key, item in value.items(): - key = _clean(key) - item = _label_value(item) + key = clean_string(key) + item = normalize_label_value(item) if key and item is not None: labels[key] = item return labels @@ -136,7 +105,7 @@ def normalize_datadog_tags(value): labels.update(normalize_datadog_tags(item)) continue - item = _clean(item) + item = clean_string(item) if not item: continue @@ -152,20 +121,6 @@ def normalize_datadog_tags(value): return labels -def _stable_dedup_labels(labels): - ignored = { - "event_link", - "datadog_alert_transition", - "datadog_alert_status", - } - - return { - key: labels[key] - for key in sorted(labels) - if key not in ignored - } - - def normalize_datadog(payload): """Normalize a Datadog Webhooks integration payload.""" @@ -175,25 +130,25 @@ def normalize_datadog(payload): raw_labels = _get(data, "labels") if isinstance(raw_labels, dict): for key, value in raw_labels.items(): - key = _clean(key) - value = _label_value(value) + key = clean_string(key) + value = normalize_label_value(value) if key and value is not None: labels[key] = value labels.update(normalize_datadog_tags(_get(data, "tags"))) labels.update(normalize_datadog_tags(_get(data, "alert_scope", "scope"))) - alert_id = _clean(_get(data, "alert_id", "monitor_id")) - event_id = _clean(_get(data, "id", "event_id")) - alert_cycle_key = _clean(_get(data, "alert_cycle_key")) - aggreg_key = _clean(_get(data, "aggreg_key", "aggregation_key")) - transition = _clean(_get(data, "alert_transition", "transition")) - alert_type = _clean(_get(data, "alert_type", "event_alert_type")) - priority = _clean(_get(data, "alert_priority", "priority")) - alert_scope = _clean(_get(data, "alert_scope", "scope")) - hostname = _clean(_get(data, "hostname", "host")) - event_type = _clean(_get(data, "event_type")) - metric = _clean(_get(data, "alert_metric", "metric")) + alert_id = clean_string(_get(data, "alert_id", "monitor_id")) + event_id = clean_string(_get(data, "id", "event_id")) + alert_cycle_key = clean_string(_get(data, "alert_cycle_key")) + aggreg_key = clean_string(_get(data, "aggreg_key", "aggregation_key")) + transition = clean_string(_get(data, "alert_transition", "transition")) + alert_type = clean_string(_get(data, "alert_type", "event_alert_type")) + priority = clean_string(_get(data, "alert_priority", "priority")) + alert_scope = clean_string(_get(data, "alert_scope", "scope")) + hostname = clean_string(_get(data, "hostname", "host")) + event_type = clean_string(_get(data, "event_type")) + metric = clean_string(_get(data, "alert_metric", "metric")) fixed_labels = { "datadog_alert_id": alert_id, @@ -263,7 +218,14 @@ def normalize_datadog(payload): "datadog", external_id=alert_id or event_id, title=title, - labels=_stable_dedup_labels(labels), + labels=stable_labels( + labels, + exclude={ + "event_link", + "datadog_alert_transition", + "datadog_alert_status", + }, + ), ) team_slug = first_non_empty( diff --git a/app/services/integrations/normalizers/registry.py b/app/services/integrations/normalizers/registry.py index 4c63874..9d0a8f4 100644 --- a/app/services/integrations/normalizers/registry.py +++ b/app/services/integrations/normalizers/registry.py @@ -16,6 +16,7 @@ from app.services.integrations.normalizers.librenms import normalize_librenms from app.services.integrations.normalizers.rmon import normalize_rmon from app.services.integrations.normalizers.sentry import normalize_sentry +from app.services.integrations.normalizers.uptime_kuma import normalize_uptime_kuma from app.services.integrations.normalizers.webhook import normalize_webhook from app.services.integrations.normalizers.zabbix import normalize_zabbix @@ -62,6 +63,7 @@ def _normalize_sentry( "librenms": _payload_only(normalize_librenms), "rmon": _payload_only(normalize_rmon), "sentry": _normalize_sentry, + "uptime_kuma": _payload_only(normalize_uptime_kuma), "webhook": _payload_only(normalize_webhook), "zabbix": _payload_only(normalize_zabbix), } diff --git a/app/services/integrations/normalizers/uptime_kuma.py b/app/services/integrations/normalizers/uptime_kuma.py new file mode 100644 index 0000000..7817e73 --- /dev/null +++ b/app/services/integrations/normalizers/uptime_kuma.py @@ -0,0 +1,302 @@ +from typing import Any, Mapping + +from app.services.integrations.normalizers.common import ( + add_event_link_label, + canonical_label_key, + clean_string, + first_event_link, + first_non_empty, + first_present, + make_dedup_key, + severity_from_priority, + stable_labels, +) +from app.services.severity import normalize_severity + + +UP_STATUSES = {1, "1", "up", "ok", "healthy", "resolved", "recovered"} +DOWN_STATUSES = {0, "0", "down", "fail", "failed", "failing", "unhealthy"} +PENDING_STATUSES = {2, "2", "pending", "retrying"} +MAINTENANCE_STATUSES = {3, "3", "maintenance", "paused"} + +STATUS_LABELS = { + "resolved": "up", + "firing": "down", + "pending": "pending", + "maintenance": "maintenance", + "unknown": "unknown", +} + + +def _set_label(labels: dict[str, Any], key: str, value: Any) -> None: + if value is None: + return + + if isinstance(value, bool): + labels.setdefault(key, "true" if value else "false") + return + + if isinstance(value, (str, int, float)): + value = str(value).strip() + if value: + labels.setdefault(key, value) + + +def normalize_uptime_kuma_state(value: Any) -> str: + """Return a descriptive Uptime Kuma state name.""" + + normalized: Any = value + if isinstance(value, str): + normalized = value.strip().lower() + + if normalized in UP_STATUSES: + return "resolved" + if normalized in MAINTENANCE_STATUSES: + return "maintenance" + if normalized in PENDING_STATUSES: + return "pending" + if normalized in DOWN_STATUSES: + return "firing" + + return "unknown" + + +def normalize_uptime_kuma_status(value: Any) -> str: + """Map Uptime Kuma status to IncidentRelay firing/resolved lifecycle.""" + + state = normalize_uptime_kuma_state(value) + if state in {"resolved", "maintenance"}: + return "resolved" + return "firing" + + +def normalize_uptime_kuma_severity(value: Any, default: str = "critical") -> str: + """Map Uptime Kuma tags or custom priority values to severity.""" + + priority_severity = severity_from_priority(value) + if priority_severity: + return priority_severity + + normalized = str(value or "").strip().lower() + return normalize_severity(normalized) or default + + +def normalize_uptime_kuma_tags(value: Any) -> dict[str, str]: + """Convert Uptime Kuma monitor tags to matcher-friendly labels.""" + + result: dict[str, str] = {} + + if value is None: + return result + + if isinstance(value, Mapping): + items = [value] + elif isinstance(value, (list, tuple, set)): + items = list(value) + else: + items = [value] + + for item in items: + name = None + tag_value = None + + if isinstance(item, Mapping): + name = first_non_empty( + item.get("name"), + item.get("tag_name"), + item.get("key"), + ) + tag_value = first_non_empty( + item.get("value"), + item.get("tag_value"), + ) + else: + text = clean_string(item) + if not text: + continue + if ":" in text: + name, tag_value = text.split(":", 1) + elif "=" in text: + name, tag_value = text.split("=", 1) + else: + name = text + + key = canonical_label_key(name) + if not key: + continue + + value_text = clean_string(tag_value) or "true" + result.setdefault(f"uptime_kuma_tag_{key}", value_text) + + # Common routing tags are also exposed without the integration prefix. + if key in { + "team", + "oncall_team", + "service", + "environment", + "env", + "severity", + "priority", + "region", + "cluster", + }: + result.setdefault(key, value_text) + + return result + + +def _monitor_target(monitor: Mapping[str, Any]) -> str | None: + url = clean_string(monitor.get("url")) + if url: + return url + + hostname = first_non_empty( + monitor.get("hostname"), + monitor.get("host"), + monitor.get("dns_resolve_server"), + ) + port = clean_string(monitor.get("port")) + + if hostname and port: + return f"{hostname}:{port}" + + return clean_string(hostname) + + +def normalize_uptime_kuma(payload: Mapping[str, Any]) -> list[dict[str, Any]]: + """Normalize the standard Uptime Kuma Webhook notification payload.""" + + monitor = dict(payload.get("monitor") or {}) + heartbeat = dict(payload.get("heartbeat") or {}) + labels = dict(payload.get("labels") or {}) + + labels.update(normalize_uptime_kuma_tags(monitor.get("tags"))) + + monitor_id = first_non_empty( + monitor.get("id"), + heartbeat.get("monitorID"), + heartbeat.get("monitorId"), + payload.get("monitor_id"), + ) + monitor_name = first_non_empty( + monitor.get("name"), + payload.get("name"), + ) + monitor_type = first_non_empty( + monitor.get("type"), + payload.get("monitor_type"), + ) + target = _monitor_target(monitor) + raw_status = first_present( + heartbeat.get("status"), + payload.get("status"), + ) + normalized_state = normalize_uptime_kuma_state(raw_status) + status = normalize_uptime_kuma_status(raw_status) + + _set_label(labels, "uptime_kuma_monitor_id", monitor_id) + _set_label(labels, "uptime_kuma_monitor_name", monitor_name) + _set_label(labels, "uptime_kuma_monitor_type", monitor_type) + _set_label(labels, "uptime_kuma_status", STATUS_LABELS[normalized_state]) + _set_label(labels, "uptime_kuma_status_code", raw_status) + _set_label(labels, "uptime_kuma_target", target) + _set_label(labels, "uptime_kuma_hostname", monitor.get("hostname")) + _set_label(labels, "uptime_kuma_port", monitor.get("port")) + _set_label(labels, "uptime_kuma_ping_ms", heartbeat.get("ping")) + _set_label( + labels, + "uptime_kuma_duration_seconds", + heartbeat.get("duration"), + ) + _set_label( + labels, + "uptime_kuma_local_datetime", + first_non_empty( + heartbeat.get("localDateTime"), + heartbeat.get("time"), + ), + ) + + event_link = first_event_link( + payload.get("event_link"), + payload.get("monitor_url"), + monitor.get("dashboardURL"), + monitor.get("dashboard_url"), + target if str(target or "").startswith(("http://", "https://")) else None, + ) + add_event_link_label(labels, event_link) + + title = first_non_empty( + monitor_name, + payload.get("title"), + "Uptime Kuma notification", + ) + message = first_non_empty( + heartbeat.get("msg"), + payload.get("msg"), + payload.get("message"), + target, + "", + ) + + explicit_severity = first_non_empty( + payload.get("severity"), + monitor.get("severity"), + labels.get("severity"), + labels.get("priority"), + ) + default_severity = "info" if not monitor and not heartbeat else "critical" + severity = normalize_uptime_kuma_severity( + explicit_severity, + default=default_severity, + ) + + external_id = clean_string(monitor_id) + if external_id: + dedup_key = f"uptime-kuma:{external_id}" + else: + dedup_key = make_dedup_key( + "uptime_kuma", + external_id=first_non_empty(monitor_name, target), + title=title, + labels=stable_labels( + labels, + exclude={ + "event_link", + "uptime_kuma_status", + "uptime_kuma_status_code", + "uptime_kuma_ping_ms", + "uptime_kuma_duration_seconds", + "uptime_kuma_local_datetime", + }, + ), + ) + + return [{ + "source": "uptime_kuma", + "team_slug": ( + labels.get("team") + or labels.get("oncall_team") + or payload.get("team") + ), + "external_id": external_id, + "dedup_key": dedup_key, + "title": title, + "message": message or "", + "severity": severity, + "labels": labels, + "annotations": { + "uptime_kuma_state": STATUS_LABELS[normalized_state], + }, + "payload": dict(payload), + "status": status, + }] + + +__all__ = [ + "normalize_uptime_kuma", + "normalize_uptime_kuma_state", + "normalize_uptime_kuma_status", + "normalize_uptime_kuma_severity", + "normalize_uptime_kuma_tags", +] diff --git a/app/services/integrations/normalizers/webhook.py b/app/services/integrations/normalizers/webhook.py index a126476..e31d1bc 100644 --- a/app/services/integrations/normalizers/webhook.py +++ b/app/services/integrations/normalizers/webhook.py @@ -1,11 +1,12 @@ -import json from copy import deepcopy from app.services.integrations.normalizers.common import ( add_event_link_label, + clean_string, first_event_link, first_non_empty, make_dedup_key, + normalize_label_value, ) from app.services.severity import normalize_severity @@ -23,29 +24,6 @@ def is_pagerduty_events_v2(payload): return action in PAGERDUTY_EVENT_ACTIONS -def _clean_string(value): - if value is None: - return None - - value = str(value).strip() - return value or None - - -def _label_value(value): - """Convert custom detail values into matcher-friendly label values.""" - - if value is None: - return None - - if isinstance(value, (str, int, float, bool)): - return value - - try: - return json.dumps(value, ensure_ascii=False, sort_keys=True) - except (TypeError, ValueError): - return str(value) - - def _sanitized_payload(payload): """Copy the source payload without persisting the secret routing key.""" @@ -63,7 +41,7 @@ def _pagerduty_labels(payload, event_payload, action): labels.setdefault("pagerduty_format", "events_api_v2") for key in ("source", "component", "group", "class"): - value = _clean_string(event_payload.get(key)) + value = clean_string(event_payload.get(key)) if value is not None: labels.setdefault(key, value) @@ -72,17 +50,17 @@ def _pagerduty_labels(payload, event_payload, action): if isinstance(custom_details, dict): for key, value in custom_details.items(): - key = _clean_string(key) + key = clean_string(key) if not key or key in labels: continue - value = _label_value(value) + value = normalize_label_value(value) if value is not None: labels[key] = value - client = _clean_string(payload.get("client")) + client = clean_string(payload.get("client")) if client: labels.setdefault("pagerduty_client", client) @@ -116,9 +94,9 @@ def _normalize_pagerduty_events_v2(payload): event_link = _pagerduty_event_link(payload) add_event_link_label(labels, event_link) - dedup_key = _clean_string(payload.get("dedup_key")) - summary = _clean_string(event_payload.get("summary")) - source = _clean_string(event_payload.get("source")) + dedup_key = clean_string(payload.get("dedup_key")) + summary = clean_string(event_payload.get("summary")) + source = clean_string(event_payload.get("source")) title = summary or ( f"PagerDuty event {dedup_key}" diff --git a/app/static/i18n/de/routes.json b/app/static/i18n/de/routes.json index d73925b..d47d1f4 100644 --- a/app/static/i18n/de/routes.json +++ b/app/static/i18n/de/routes.json @@ -193,9 +193,13 @@ "matcher.preset.disabled_hint": "Dieses Preset ist deaktiviert. Das Matching wird nicht erfolgreich sein, bis das Preset aktiviert ist.", "matcher.preset.match_both": "Preset \"{name}\" v{version} und die zusätzlichen Matcher müssen beide übereinstimmen.", "routes.form.datadog_help": "Erstellen Sie eine Datadog-Webhooks-Integration, die den empfohlenen benutzerdefinierten Payload an diese Route sendet. Fügen Sie das Route-Intake-Token als benutzerdefinierten Header Authorization: Bearer hinzu.", + "routes.form.uptime_kuma_help": "Uptime Kuma sendet den standardmäßigen Webhook-JSON-Payload an diese Route. Fügen Sie das Route-Intake-Token als Authorization: Bearer-Header hinzu. DOWN öffnet einen Alert, UP oder Wartung löst ihn auf.", "routes.intake.datadog_example_comment": "Beispiel für einen benutzerdefinierten Datadog-Webhooks-Payload", "routes.intake.datadog_subtitle": "Verwenden Sie diesen Endpunkt in der Datadog-Webhooks-Integration und fügen Sie das Route-Token als benutzerdefinierten Header Authorization: Bearer hinzu.", "routes.intake.datadog_help": "Konfigurieren Sie einen JSON-Payload mit ALERT_CYCLE_KEY und ALERT_TRANSITION, damit Recovery dasselbe IncidentRelay-Alert aktualisiert.", + "routes.intake.uptime_kuma_example_comment": "Beispiel für den standardmäßigen Uptime-Kuma-Webhook-Payload", + "routes.intake.uptime_kuma_subtitle": "Verwenden Sie diesen Endpoint und das Route-Token in einer Uptime-Kuma-Webhook-Benachrichtigung.", + "routes.intake.uptime_kuma_help": "Erstellen Sie in Uptime Kuma eine Webhook-Benachrichtigung mit POST, application/json, der Route-Intake-URL und einem zusätzlichen Authorization-Header. Behalten Sie den Standard-Request-Body bei.", "routes.form.webhook_pd_help": "Webhook-Routen akzeptieren generische IncidentRelay-Payloads sowie PagerDuty-Events-API-v2-kompatible Trigger-, Acknowledge- und Resolve-Ereignisse. Verwenden Sie das Intake-Token der Route als routing_key.", "routes.intake.generic_example_comment": "Generisches IncidentRelay-Webhook-Format", "routes.intake.pagerduty_example_comment": "PagerDuty-Events-API-v2-kompatibles Format", diff --git a/app/static/i18n/en/routes.json b/app/static/i18n/en/routes.json index e03ff9b..fb2b9ce 100644 --- a/app/static/i18n/en/routes.json +++ b/app/static/i18n/en/routes.json @@ -198,7 +198,11 @@ "routes.intake.webhook_subtitle": "Use this token as Authorization: Bearer for generic payloads or as routing_key for PagerDuty Events API v2-compatible payloads.", "routes.intake.webhook_help": "Generic payloads use Authorization: Bearer. PagerDuty Events API v2-compatible payloads put the same token in routing_key.", "routes.form.datadog_help": "Create a Datadog Webhooks integration that sends the recommended custom payload to this route. Add the route intake token as an Authorization: Bearer custom header.", + "routes.form.uptime_kuma_help": "Uptime Kuma sends its standard Webhook JSON payload to this route. Add the route intake token as an Authorization: Bearer header. DOWN opens an alert and UP or maintenance resolves it.", "routes.intake.datadog_example_comment": "Datadog Webhooks custom payload example", "routes.intake.datadog_subtitle": "Use this endpoint in the Datadog Webhooks integration and add the route token as an Authorization: Bearer custom header.", - "routes.intake.datadog_help": "Configure a JSON custom payload with ALERT_CYCLE_KEY and ALERT_TRANSITION so recovery updates the same IncidentRelay alert." + "routes.intake.datadog_help": "Configure a JSON custom payload with ALERT_CYCLE_KEY and ALERT_TRANSITION so recovery updates the same IncidentRelay alert.", + "routes.intake.uptime_kuma_example_comment": "Standard Uptime Kuma Webhook payload example", + "routes.intake.uptime_kuma_subtitle": "Use this endpoint and route token in an Uptime Kuma Webhook notification.", + "routes.intake.uptime_kuma_help": "In Uptime Kuma, create a Webhook notification with POST, application/json, the route intake URL, and an additional Authorization header. Keep the default request body." } diff --git a/app/static/i18n/ru/routes.json b/app/static/i18n/ru/routes.json index 07c8f72..974aace 100644 --- a/app/static/i18n/ru/routes.json +++ b/app/static/i18n/ru/routes.json @@ -198,7 +198,11 @@ "routes.intake.webhook_subtitle": "Для обычного payload используйте токен как Authorization: Bearer, а для формата PagerDuty Events API v2 — как routing_key.", "routes.intake.webhook_help": "Обычный payload использует Authorization: Bearer. Совместимый с PagerDuty Events API v2 payload передаёт тот же токен в routing_key.", "routes.form.datadog_help": "Создайте Datadog Webhooks integration, которая отправляет рекомендуемый custom payload в этот маршрут. Добавьте intake token маршрута в custom header Authorization: Bearer.", + "routes.form.uptime_kuma_help": "Uptime Kuma отправляет в этот маршрут стандартный JSON Webhook. Добавьте intake token маршрута в заголовок Authorization: Bearer. DOWN открывает алерт, а UP или maintenance закрывает его.", "routes.intake.datadog_example_comment": "Пример custom payload для Datadog Webhooks", "routes.intake.datadog_subtitle": "Используйте этот endpoint в Datadog Webhooks integration и добавьте токен маршрута в custom header Authorization: Bearer.", - "routes.intake.datadog_help": "Настройте JSON custom payload с ALERT_CYCLE_KEY и ALERT_TRANSITION, чтобы recovery обновлял тот же алерт IncidentRelay." + "routes.intake.datadog_help": "Настройте JSON custom payload с ALERT_CYCLE_KEY и ALERT_TRANSITION, чтобы recovery обновлял тот же алерт IncidentRelay.", + "routes.intake.uptime_kuma_example_comment": "Пример стандартного Webhook payload Uptime Kuma", + "routes.intake.uptime_kuma_subtitle": "Используйте этот endpoint и token маршрута в Webhook notification Uptime Kuma.", + "routes.intake.uptime_kuma_help": "В Uptime Kuma создайте Webhook notification с методом POST, типом application/json, intake URL маршрута и дополнительным заголовком Authorization. Оставьте стандартный request body." } diff --git a/app/static/js/pages/routes.js b/app/static/js/pages/routes.js index 7d27a98..0898103 100644 --- a/app/static/js/pages/routes.js +++ b/app/static/js/pages/routes.js @@ -328,7 +328,8 @@ function fillRouteSourceFilter(routes) { zabbix: true, webhook: true, sentry: true, - librenms: true + librenms: true, + uptime_kuma: true }; asArray(routes).forEach(function (route) { @@ -1098,6 +1099,7 @@ function updateRouteSourceUi() { const isSentry = source === "sentry"; const isAwsSns = source === "aws_sns"; const isDatadog = source === "datadog"; + const isUptimeKuma = source === "uptime_kuma"; const isWebhook = source === "webhook"; $("#route-sentry-settings").toggleClass("is-hidden", !isSentry); @@ -1109,6 +1111,10 @@ function updateRouteSourceUi() { "is-hidden", !isDatadog ); + $("#route-uptime-kuma-help").toggleClass( + "is-hidden", + !isUptimeKuma + ); $("#route-aws-sns-settings").toggleClass("is-hidden", !isAwsSns); @@ -1142,6 +1148,10 @@ function updateRouteSourceUi() { $("#route-group-by").val( '["rmon_check_id","rmon_check_type"]' ); + } else if (source === "uptime_kuma") { + $("#route-group-by").val( + '["uptime_kuma_monitor_id"]' + ); } else if (source === "aws_sns") { $("#route-group-by").val( '["cloudwatch_alarm_arn"]' @@ -1497,6 +1507,10 @@ function getRouteIntakePath(route) { return "/api/integrations/zabbix"; } + if (source === "uptime_kuma") { + return "/api/integrations/uptime-kuma"; + } + if (source === "webhook") { return "/api/integrations/webhook"; } @@ -1540,6 +1554,16 @@ function buildRouteIntakeCurl(route, token) { ].join("\n"); } + if (source === "uptime_kuma") { + return [ + "# " + i18n.t("routes.intake.uptime_kuma_example_comment"), + `curl -X POST '${url}' \\`, + " -H 'Content-Type: application/json' \\", + ` -H 'Authorization: Bearer ${token || ""}' \\`, + " -d '{\"heartbeat\":{\"monitorID\":42,\"status\":0,\"msg\":\"Connection refused\",\"ping\":null},\"monitor\":{\"id\":42,\"name\":\"Production API\",\"type\":\"http\",\"url\":\"https://api.example.com\",\"tags\":[{\"name\":\"team\",\"value\":\"sre\"}]},\"msg\":\"Production API is DOWN\"}'" + ].join("\n"); + } + if (source === "webhook") { return [ "# " + i18n.t("routes.intake.generic_example_comment"), @@ -1569,6 +1593,7 @@ function showRouteIntakeDetails(route) { const isSentry = source === "sentry"; const isHeartbeat = source === "heartbeat"; const isDatadog = source === "datadog"; + const isUptimeKuma = source === "uptime_kuma"; const isWebhook = source === "webhook"; const url = getRouteIntakeUrl(route); @@ -1587,6 +1612,9 @@ function showRouteIntakeDetails(route) { } else if (isDatadog) { subtitleKey = "routes.intake.datadog_subtitle"; helpKey = "routes.intake.datadog_help"; + } else if (isUptimeKuma) { + subtitleKey = "routes.intake.uptime_kuma_subtitle"; + helpKey = "routes.intake.uptime_kuma_help"; } else if (isWebhook) { subtitleKey = "routes.intake.webhook_subtitle"; helpKey = "routes.intake.webhook_help"; diff --git a/app/templates/pages/routes.html b/app/templates/pages/routes.html index 5e48c1c..be28624 100644 --- a/app/templates/pages/routes.html +++ b/app/templates/pages/routes.html @@ -182,6 +182,7 @@

{{ _("routes.form.route_section") }}

+ +