diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 165605c25..e1f01a88c 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -839,7 +839,8 @@ async def preview_iwooos_wazuh_controlled_executor_handoff( response_model=dict[str, Any], summary="取得 Wazuh controlled executor live runtime receipt 讀回", description=( - "讀取內部 worker 排程的 Wazuh manager bounded no-write posture executor " + "優先讀取內部 worker 排程的 Wazuh sanitized alert-ingress convergence," + "尚無 ingress candidate 時才回退到 manager bounded no-write posture executor;" "candidate、check-mode、execution、independent verifier、KM / RAG / PlayBook、" "MCP context 與 Telegram durable receipts。此端點不執行命令、不回傳 command output、" "inventory identity、主機位址、raw Wazuh payload 或機密值。" diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index 593e1aa1c..c22fb919a 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -836,8 +836,8 @@ class Settings(BaseSettings): ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR: bool = Field( default=False, description=( - "True=periodically enqueue the allowlisted no-write Wazuh manager " - "posture playbook into the existing Ansible controlled executor." + "True=periodically converge the allowlisted Wazuh sanitized alert " + "ingress before enqueueing the no-write manager posture playbook." ), ) IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS: int = Field( @@ -845,7 +845,7 @@ class Settings(BaseSettings): ge=1, le=24, description=( - "Minimum freshness window between Wazuh manager posture executor runs." + "Minimum freshness window between Wazuh ingress and posture runs." ), ) AWOOOP_ANSIBLE_CONTROLLED_APPLY_ALLOWED_RISK_LEVELS: str = Field( @@ -901,6 +901,13 @@ class Settings(BaseSettings): default="/etc/ssh-mcp/known_hosts", description="known_hosts path for Ansible check-mode SSH transport.", ) + AWOOOP_ANSIBLE_BECOME_PASSWORD_FILE: str = Field( + default="/run/secrets/host112-become/password", + description=( + "Optional Ansible become password file. The value is consumed by " + "Ansible through its password-file contract and never placed in argv." + ), + ) AWOOOP_ANSIBLE_CHECK_MODE_CANDIDATE_MAX_AGE_HOURS: int = Field( default=24, ge=1, diff --git a/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py b/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py index 504116b34..35a9994d7 100644 --- a/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py +++ b/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py @@ -61,6 +61,8 @@ def _error_backoff_seconds() -> float: _WAZUH_POSTURE_CATALOG_ID = "ansible:wazuh-manager-posture-readback" _WAZUH_POSTURE_WORK_ITEM_ID = "P0-03-WAZUH-MANAGER-POSTURE" _WAZUH_POSTURE_PUBLIC_ASSET_ALIAS = "security_manager_primary" +_WAZUH_INGRESS_CATALOG_ID = "ansible:wazuh-alertmanager-integration" +_WAZUH_INGRESS_WORK_ITEM_ID = "P0-03-WAZUH-ALERT-INGRESS" async def _fetch_missing_candidate_incidents( @@ -559,12 +561,75 @@ def _wazuh_posture_incident( } +def _wazuh_ingress_incident( + *, + automation_run_id: str, + bucket_ref: str, + attempt_ref: str | None = None, +) -> dict[str, Any]: + receipt_ref = ( + f"{bucket_ref}-{attempt_ref.upper()}" if attempt_ref else bucket_ref + ) + incident_id = f"IWZ-INGRESS-{bucket_ref}" + if attempt_ref: + attempt_token = uuid5( + NAMESPACE_URL, + f"awoooi:iwooos:wazuh-alert-ingress-attempt:{attempt_ref}", + ).hex[:12].upper() + incident_id = f"IWZ-I-{bucket_ref}-{attempt_token}" + return { + "incident_id": incident_id, + "project_id": "awoooi", + "status": "INVESTIGATING", + "severity": "high", + "alertname": "WazuhAlertmanagerIntegrationConvergence", + "alert_category": "security", + "notification_type": "controlled_security_configuration", + "trace_id": automation_run_id, + "run_id": automation_run_id, + "work_item_id": _WAZUH_INGRESS_WORK_ITEM_ID, + "source_receipt_ref": f"scheduled-wazuh-alert-ingress:{receipt_ref}", + "asset_scope_aliases": [_WAZUH_POSTURE_PUBLIC_ASSET_ALIAS], + "affected_services": ["wazuh-manager", "siem"], + "signals": [ + { + "alert_name": "WazuhAlertmanagerIntegrationConvergence", + "source": "iwooos_scheduler", + "labels": { + "alertname": "WazuhAlertmanagerIntegrationConvergence", + "service": "wazuh-manager", + "component": "wazuh-manager", + "tool": "wazuh", + "asset_alias": _WAZUH_POSTURE_PUBLIC_ASSET_ALIAS, + }, + "annotations": { + "summary": ( + "converge sanitized Wazuh event ingress with bounded " + "rollback and independent verification" + ) + }, + } + ], + } + + async def enqueue_iwooos_wazuh_manager_posture_candidate_once( *, project_id: str = "awoooi", recorder: Recorder = record_ansible_decision_audit, evidence_collector: EvidenceCollector = collect_ansible_candidate_pre_decision_evidence, evidence_verifier: EvidenceVerifier = verify_ansible_candidate_pre_decision_evidence, + _catalog_id: str = _WAZUH_POSTURE_CATALOG_ID, + _work_item_id: str = _WAZUH_POSTURE_WORK_ITEM_ID, + _incident_factory: Callable[..., dict[str, Any]] = _wazuh_posture_incident, + _run_namespace: str = "awoooi:iwooos:wazuh-manager-posture", + _scheduler_source: str = "iwooos_wazuh_manager_posture_scheduler", + _proposal_action: str = "run_bounded_no_write_wazuh_manager_posture_readback", + _risk_level: str = "low", + _reason: str = ( + "scheduled Wazuh manager posture readback entered the allowlisted " + "check-mode and independent verifier chain" + ), ) -> dict[str, Any]: """Enqueue one fresh, no-write Wazuh manager posture executor run.""" @@ -648,7 +713,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( """), { "catalog_match": json.dumps( - [{"catalog_id": _WAZUH_POSTURE_CATALOG_ID}] + [{"catalog_id": _catalog_id}] ), "freshness_hours": freshness_hours, }, @@ -679,7 +744,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( or existing_row.get("op_id") or "" ), - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "active_blockers": [], } if existing_row: @@ -710,7 +775,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( or existing_row.get("op_id") or "" ), - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "retry_reason": retry_reason, "active_blockers": [ "predecision_mcp_or_log_evidence_missing" @@ -736,7 +801,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( or existing_row.get("op_id") or "" ), - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "retry_reason": retry_reason, "retry_of_failed_check_mode": ( retry_reason == "check_mode_failed" @@ -761,11 +826,11 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( automation_run_id = str( uuid5( NAMESPACE_URL, - "awoooi:iwooos:wazuh-manager-posture:" + f"{_run_namespace}:" f"{bucket.isoformat()}:{retry_attempt_ref or 'initial'}", ) ) - incident = _wazuh_posture_incident( + incident = _incident_factory( automation_run_id=automation_run_id, bucket_ref=bucket.strftime("%Y%m%d%H"), attempt_ref=retry_attempt_ref, @@ -794,7 +859,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( "existing": 1 if existing_row else 0, "evidence_ready": 0, "automation_run_id": automation_run_id, - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "retry_reason": retry_reason, "retry_of_failed_check_mode": retry_reason == "check_mode_failed", "retry_of_incomplete_predecision_context": ( @@ -809,19 +874,36 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( ], } + proposal_data = { + "source": _scheduler_source, + "risk_level": _risk_level, + "execution_priority": 0, + "action": _proposal_action, + } + if build_ansible_decision_audit_payload( + incident=incident, + proposal_data=proposal_data, + decision_path=_BACKFILL_DECISION_PATH, + not_used_reason=_reason, + ) is None: + return { + "status": "blocked_canonical_asset_or_ansible_catalog_unresolved", + "queued": 0, + "existing": 1 if existing_row else 0, + "evidence_ready": 1, + "automation_run_id": automation_run_id, + "work_item_id": _work_item_id, + "retry_reason": retry_reason, + "active_blockers": [ + "canonical_asset_or_ansible_catalog_unresolved" + ], + } + queued = await recorder( incident=incident, - proposal_data={ - "source": "iwooos_wazuh_manager_posture_scheduler", - "risk_level": "low", - "execution_priority": 0, - "action": "run_bounded_no_write_wazuh_manager_posture_readback", - }, + proposal_data=proposal_data, decision_path=_BACKFILL_DECISION_PATH, - not_used_reason=( - "scheduled Wazuh manager posture readback entered the allowlisted " - "check-mode and independent verifier chain" - ), + not_used_reason=_reason, automation_run_id=automation_run_id, ) return { @@ -833,12 +915,14 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( else "queued_for_controlled_executor" if queued else "candidate_race_deduplicated" + if existing_row + else "candidate_write_not_acknowledged" ), "queued": 1 if queued else 0, - "existing": 0 if queued else 1, + "existing": 1 if existing_row else 0, "evidence_ready": 1 if evidence_ready else 0, "automation_run_id": automation_run_id, - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "retry_reason": retry_reason, "retry_of_failed_check_mode": retry_reason == "check_mode_failed", "retry_of_incomplete_predecision_context": ( @@ -848,7 +932,11 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( "controlled_apply_failed", "post_apply_learning_incomplete", }, - "active_blockers": [] if queued else ["candidate_write_not_acknowledged"], + "active_blockers": ( + [] + if queued or existing_row + else ["candidate_write_not_acknowledged"] + ), } except Exception as exc: logger.warning( @@ -860,11 +948,39 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( "queued": 0, "existing": 0, "evidence_ready": 0, - "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "work_item_id": _work_item_id, "active_blockers": [f"candidate_error:{type(exc).__name__}"], } +async def enqueue_iwooos_wazuh_alertmanager_integration_candidate_once( + *, + project_id: str = "awoooi", + recorder: Recorder = record_ansible_decision_audit, + evidence_collector: EvidenceCollector = collect_ansible_candidate_pre_decision_evidence, + evidence_verifier: EvidenceVerifier = verify_ansible_candidate_pre_decision_evidence, +) -> dict[str, Any]: + """Enqueue idempotent Wazuh event-ingress convergence before posture work.""" + + return await enqueue_iwooos_wazuh_manager_posture_candidate_once( + project_id=project_id, + recorder=recorder, + evidence_collector=evidence_collector, + evidence_verifier=evidence_verifier, + _catalog_id=_WAZUH_INGRESS_CATALOG_ID, + _work_item_id=_WAZUH_INGRESS_WORK_ITEM_ID, + _incident_factory=_wazuh_ingress_incident, + _run_namespace="awoooi:iwooos:wazuh-alert-ingress", + _scheduler_source="iwooos_wazuh_alert_ingress_scheduler", + _proposal_action="converge_wazuh_alertmanager_integration", + _risk_level="high", + _reason=( + "scheduled Wazuh alert ingress convergence entered the allowlisted " + "check-mode, bounded apply, rollback, and independent verifier chain" + ), + ) + + def _wazuh_posture_retry_reason( existing_row: Mapping[str, Any] | None, ) -> str | None: @@ -1118,6 +1234,9 @@ async def run_awooop_ansible_candidate_backfill_loop() -> None: settings.AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_INTERVAL_SECONDS ) try: + ingress_result = ( + await enqueue_iwooos_wazuh_alertmanager_integration_candidate_once() + ) posture_result = await enqueue_iwooos_wazuh_manager_posture_candidate_once() result = await enqueue_missing_ansible_candidates_once( limit=settings.AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_BATCH_LIMIT, @@ -1136,6 +1255,11 @@ async def run_awooop_ansible_candidate_backfill_loop() -> None: "iwooos_wazuh_manager_posture_candidate_tick", **posture_result, ) + if ingress_result.get("queued") or ingress_result.get("active_blockers"): + logger.info( + "iwooos_wazuh_alert_ingress_candidate_tick", + **ingress_result, + ) except Exception as exc: logger.warning( "awooop_ansible_candidate_backfill_worker_failed", error=str(exc) diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index 8e97dc9d3..a06f9fc5b 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -7190,6 +7190,19 @@ async def _load_core_runtime_receipt_rows_direct( return None +def build_ai_agent_strict_runtime_completion_from_readback( + readback: Mapping[str, Any], +) -> dict[str, Any]: + """Project the canonical strict completion contract from one readback.""" + + payload = build_ai_agent_autonomous_runtime_control() + _attach_runtime_receipt_readback(payload, dict(readback)) + strict_runtime = payload.get("strict_runtime_completion") + if not isinstance(strict_runtime, Mapping): + return {} + return copy.deepcopy(dict(strict_runtime)) + + async def build_ai_agent_autonomous_runtime_control_with_live_readback( *, project_id: str = _DEFAULT_PROJECT_ID, diff --git a/apps/api/src/services/awooop_ansible_audit_service.py b/apps/api/src/services/awooop_ansible_audit_service.py index b60288396..5e7b5fe0e 100644 --- a/apps/api/src/services/awooop_ansible_audit_service.py +++ b/apps/api/src/services/awooop_ansible_audit_service.py @@ -514,6 +514,36 @@ _CATALOG: tuple[dict[str, Any], ...] = ( "risk_level": "low", "no_write_only": True, }, + { + "catalog_id": "ansible:wazuh-alertmanager-integration", + "playbook_path": ( + "infra/ansible/playbooks/wazuh-alertmanager-integration.yml" + ), + "check_mode_playbook_path": ( + "infra/ansible/playbooks/wazuh-alertmanager-integration.yml" + ), + "inventory_hosts": ["host_112"], + "domains": [ + "wazuh", + "siem", + "security_event_ingress", + "alertmanager", + ], + "keywords": [ + "wazuhalertmanagerintegrationconvergence", + "wazuh alertmanager integration convergence", + "custom-awoooi-alertmanager", + "security event ingress", + ], + "activation_keywords": [ + "wazuhalertmanagerintegrationconvergence", + ], + "supports_check_mode": True, + "auto_apply_enabled": True, + "approval_required": False, + "risk_level": "high", + "catalog_revision": "2026-07-15-wazuh-alert-ingress-v1", + }, { "catalog_id": "ansible:110-host-pressure-readonly", "playbook_path": "infra/ansible/playbooks/110-host-pressure-readonly.yml", diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index 271f598d0..d0ed335c8 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -634,17 +634,33 @@ def _resolve_playbook_path(playbook_root: Path, playbook_path: str) -> Path: return resolved -def _ansible_command_env(playbook_root: Path) -> dict[str, str]: +def _ansible_command_env( + playbook_root: Path, + *, + allow_become_password_file: bool = False, +) -> dict[str, str]: roles_path = str((playbook_root / "roles").resolve()) existing_roles_path = os.environ.get("ANSIBLE_ROLES_PATH") if existing_roles_path: roles_path = os.pathsep.join([roles_path, existing_roles_path]) - return { + env = { **os.environ, "ANSIBLE_HOST_KEY_CHECKING": "true", "ANSIBLE_RETRY_FILES_ENABLED": "false", "ANSIBLE_ROLES_PATH": roles_path, } + become_password_file = Path( + settings.AWOOOP_ANSIBLE_BECOME_PASSWORD_FILE + ) + if ( + allow_become_password_file + and become_password_file.is_file() + and os.access(become_password_file, os.R_OK) + ): + env["ANSIBLE_BECOME_PASSWORD_FILE"] = str(become_password_file) + else: + env.pop("ANSIBLE_BECOME_PASSWORD_FILE", None) + return env def build_ansible_check_mode_command( @@ -687,7 +703,14 @@ def build_ansible_check_mode_command( "--extra-vars", json.dumps(extra_vars, ensure_ascii=False, separators=(",", ":")), ] - env = _ansible_command_env(root) + env = _ansible_command_env( + root, + allow_become_password_file=( + playbook_path + == "infra/ansible/playbooks/wazuh-alertmanager-integration.yml" + and inventory_hosts == ("host_112",) + ), + ) return AnsibleCommandSpec( command=command, cwd=root, @@ -738,7 +761,14 @@ def build_ansible_apply_command( "--extra-vars", json.dumps(extra_vars, ensure_ascii=False, separators=(",", ":")), ] - env = _ansible_command_env(root) + env = _ansible_command_env( + root, + allow_become_password_file=( + playbook_path + == "infra/ansible/playbooks/wazuh-alertmanager-integration.yml" + and inventory_hosts == ("host_112",) + ), + ) return AnsibleCommandSpec( command=command, cwd=root, diff --git a/apps/api/src/services/awooop_ansible_post_verifier.py b/apps/api/src/services/awooop_ansible_post_verifier.py index c3a21d668..db212a768 100644 --- a/apps/api/src/services/awooop_ansible_post_verifier.py +++ b/apps/api/src/services/awooop_ansible_post_verifier.py @@ -72,6 +72,47 @@ _POSTCONDITION_REGISTRY: dict[str, tuple[AssetPostcondition, ...]] = { "systemctl is-active --quiet wazuh-manager.service", ), ), + "ansible:wazuh-alertmanager-integration": ( + AssetPostcondition( + "host_112_wazuh_alertmanager_script", + "security", + "host_112", + "test -r /var/lib/awoooi-wazuh-alert-ingress/receipt.env && " + "grep -Fqx 'integration_name=custom-awoooi-alertmanager' " + "/var/lib/awoooi-wazuh-alert-ingress/receipt.env", + ), + AssetPostcondition( + "host_112_wazuh_alertmanager_config", + "security", + "host_112", + "grep -Fqx 'config_present=1' " + "/var/lib/awoooi-wazuh-alert-ingress/receipt.env && " + "grep -Fqx 'raw_payload_persisted=0' " + "/var/lib/awoooi-wazuh-alert-ingress/receipt.env && " + "grep -Fqx 'active_response_enabled=0' " + "/var/lib/awoooi-wazuh-alert-ingress/receipt.env", + ), + AssetPostcondition( + "host_112_wazuh_manager_runtime_after_ingress_convergence", + "service", + "host_112", + "systemctl is-active --quiet wazuh-manager.service", + ), + AssetPostcondition( + "host_112_wazuh_integrator_runtime", + "service", + "host_112", + "pgrep -x wazuh-integratord >/dev/null && " + "grep -Fqx 'integrator_running=1' " + "/var/lib/awoooi-wazuh-alert-ingress/receipt.env", + ), + AssetPostcondition( + "host_112_awoooi_alertmanager_ingress_reachable", + "network", + "host_112", + "curl -fsS --max-time 10 https://awoooi.wooo.work/api/v1/webhooks/health >/dev/null", + ), + ), "ansible:110-host-pressure-readonly": ( AssetPostcondition( "host_110_pressure_controller_asset", diff --git a/apps/api/src/services/controlled_alert_target_router.py b/apps/api/src/services/controlled_alert_target_router.py index 558c12b43..ddff584ca 100644 --- a/apps/api/src/services/controlled_alert_target_router.py +++ b/apps/api/src/services/controlled_alert_target_router.py @@ -328,6 +328,32 @@ def resolve_typed_alert_target( execution_role="single_writer_windows_vmware_executor", ) + if compact_alert == "wazuhalertmanagerintegrationconvergence": + identity = registry.resolve_identity(target_resource) + inventory_host = _host_scope(str(identity.get("host") or "")) + if ( + identity.get("resolution_status") == "resolved" + and identity.get("canonical_id") == "service:wazuh-manager" + and inventory_host is not None + and inventory_host[0] == "host_112" + ): + return _typed_route( + resolution_status="resolved", + target_kind="host_systemd", + target_resource=target_resource, + namespace=namespace, + canonical_asset_id="service:wazuh-manager", + host=inventory_host[1], + executor="host_ansible_executor", + verifier="wazuh_alert_ingress_independent_verifier", + risk_class="high", + allowed_catalog_ids=[ + "ansible:wazuh-alertmanager-integration" + ], + allowed_inventory_hosts=["host_112"], + stateful_level=str(identity.get("stateful_level") or ""), + ) + host_scope = _host_scope( str(labels.get("host") or ""), str(labels.get("instance") or ""), diff --git a/apps/api/src/services/executor_trust_boundary_readback.py b/apps/api/src/services/executor_trust_boundary_readback.py index 5f8a54318..cd2a9c3a4 100644 --- a/apps/api/src/services/executor_trust_boundary_readback.py +++ b/apps/api/src/services/executor_trust_boundary_readback.py @@ -223,6 +223,12 @@ def _build_database_identity_boundary( workloads[workload_id]["connection_limit"] - workloads[workload_id]["active_role_connection_count"] ) + and isinstance( + workloads[workload_id]["representative_connection_probe_count"], + int, + ) + and workloads[workload_id]["representative_connection_probe_count"] + >= 1 and workloads[workload_id]["available_connection_headroom"] >= workloads[workload_id]["required_connection_budget"] for workload_id in _DB_WORKLOAD_IDS @@ -326,7 +332,12 @@ def _build_database_identity_boundary( or active_role_connections < 0 or not isinstance(available_headroom, int) or available_headroom != connection_limit - active_role_connections - or available_headroom < required_budget + or not isinstance( + workload["representative_connection_probe_count"], int + ) + or workload["representative_connection_probe_count"] < 1 + or available_headroom + < required_budget ): blockers.append(f"{workload_id}_connection_budget_not_verified") if not db_identity_isolated: diff --git a/apps/api/src/services/incident_service.py b/apps/api/src/services/incident_service.py index f1f27b64a..c9845dbf1 100644 --- a/apps/api/src/services/incident_service.py +++ b/apps/api/src/services/incident_service.py @@ -194,7 +194,7 @@ def classify_alert_early( # ADR-075 TYPE-5S (2026-04-12 ogt) if any(alertname.startswith(p) for p in ( "UnauthorizedSSH", "KubeAudit", "CVECritical", "WAFAttack", - "PodAbnormal", "SecurityBreach", + "PodAbnormal", "SecurityBreach", "WazuhSecurityEvent", )): return "secops", "TYPE-5S" diff --git a/apps/api/src/services/iwooos_security_asset_control_plane.py b/apps/api/src/services/iwooos_security_asset_control_plane.py index 68ed4315b..24396cd77 100644 --- a/apps/api/src/services/iwooos_security_asset_control_plane.py +++ b/apps/api/src/services/iwooos_security_asset_control_plane.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from datetime import UTC, datetime from typing import Any @@ -11,16 +11,27 @@ import structlog from sqlalchemy import text as _sql from src.db.base import get_db_context +from src.services.ai_agent_autonomous_runtime_control import ( + build_ai_agent_strict_runtime_completion_from_readback, + load_ai_agent_autonomous_runtime_receipt_readback, +) +from src.services.ai_automation_runtime_contract import ( + AI_AUTOMATION_REQUIRED_LOOP_STAGES, + AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION, +) logger = structlog.get_logger(__name__) _SCHEMA_VERSION = "iwooos_security_asset_control_plane_v1" _QUERY_TIMEOUT_SECONDS = 12.0 _SOURCE_QUERY_TIMEOUT_SECONDS = 3.0 +_STRICT_RUNTIME_SOURCE_TIMEOUT_SECONDS = 8.5 _SOURCE_CONCURRENCY = 3 _CACHE_TTL_SECONDS = 30.0 _DISCOVERY_FRESHNESS_SECONDS = 2 * 60 * 60 _WINDOW_HOURS = 24 +_STRICT_RUNTIME_SCHEMA_VERSION = "ai_agent_strict_runtime_completion_v2" +_STRICT_RUNTIME_SOURCE_ID = "autonomous_strict_runtime" _ASSET_TYPES = ( "host", @@ -685,6 +696,126 @@ def _work_item( } +def _strict_runtime_completion_projection(row: Any) -> dict[str, Any]: + """Validate and reduce the authoritative same-run contract for public use.""" + + strict = row if isinstance(row, Mapping) else {} + expected_stage_ids = set(AI_AUTOMATION_REQUIRED_LOOP_STAGES) + required_stage_count = len(AI_AUTOMATION_REQUIRED_LOOP_STAGES) + reported_required_count = strict.get("required_stage_count") + reported_present_count = strict.get("present_stage_count") + missing_stage_ids = strict.get("missing_stage_ids") + mismatch_stage_ids = strict.get("run_id_mismatch_stage_ids") + uncorrelated_stage_ids = strict.get("uncorrelated_stage_ids") + stage_contracts = strict.get("stage_contracts") + + valid_counts = bool( + isinstance(reported_required_count, int) + and not isinstance(reported_required_count, bool) + and reported_required_count == required_stage_count + and isinstance(reported_present_count, int) + and not isinstance(reported_present_count, bool) + and 0 <= reported_present_count <= required_stage_count + ) + valid_missing = bool( + isinstance(missing_stage_ids, list) + and all(isinstance(stage_id, str) for stage_id in missing_stage_ids) + ) + valid_mismatch = bool( + isinstance(mismatch_stage_ids, list) + and all(isinstance(stage_id, str) for stage_id in mismatch_stage_ids) + ) + valid_uncorrelated = bool( + isinstance(uncorrelated_stage_ids, list) + and all(isinstance(stage_id, str) for stage_id in uncorrelated_stage_ids) + ) + valid_stage_contracts = False + if ( + isinstance(stage_contracts, list) + and len(stage_contracts) == required_stage_count + ): + stage_ids = { + str(stage.get("stage_id") or "") + for stage in stage_contracts + if isinstance(stage, Mapping) + } + valid_stage_contracts = bool( + stage_ids == expected_stage_ids + and all( + isinstance(stage, Mapping) + and stage.get("evidence_present") is True + and stage.get("same_run_correlation_proven") is True + and stage.get("completion_eligible") is True + for stage in stage_contracts + ) + ) + + contract_compatible = bool( + strict.get("schema_version") == _STRICT_RUNTIME_SCHEMA_VERSION + and strict.get("runtime_contract_schema_version") + == AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION + and valid_counts + and valid_missing + and valid_mismatch + and valid_uncorrelated + and isinstance(stage_contracts, list) + and len(stage_contracts) == required_stage_count + ) + same_run_closed_loop_proven = bool( + contract_compatible + and valid_stage_contracts + and strict.get("completion_percent") == 100 + and reported_present_count == required_stage_count + and missing_stage_ids == [] + and strict.get("latest_flow_closed") is True + and strict.get("latest_loop_closed") is True + and strict.get("execution_loop_closed") is True + and strict.get("same_run_correlation") is True + and strict.get("full_trace_same_run_correlation_proven") is True + and bool(str(strict.get("automation_run_id") or "").strip()) + and mismatch_stage_ids == [] + and uncorrelated_stage_ids == [] + and strict.get("closed") is True + and strict.get("same_run_correlation_required") is True + and strict.get("metadata_only_counts_as_completion") is False + and strict.get("historical_aggregate_fallback_allowed") is False + ) + present_stage_count = reported_present_count if valid_counts else 0 + missing_stage_count = ( + len(missing_stage_ids) + if valid_missing + else required_stage_count - present_stage_count + ) + if not strict: + reason_code = "strict_runtime_source_unavailable" + elif not contract_compatible: + reason_code = "strict_runtime_contract_mismatch" + elif not same_run_closed_loop_proven: + reason_code = "strict_runtime_receipts_incomplete" + else: + reason_code = None + + return { + "schema_version": _STRICT_RUNTIME_SCHEMA_VERSION, + "runtime_contract_schema_version": ( + AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION + ), + "contract_compatible": contract_compatible, + "completion_percent": 100 if same_run_closed_loop_proven else 0, + "required_stage_count": required_stage_count, + "present_stage_count": present_stage_count, + "missing_stage_count": missing_stage_count, + "same_run_correlation": same_run_closed_loop_proven, + "full_trace_same_run_correlation_proven": same_run_closed_loop_proven, + "execution_loop_closed": same_run_closed_loop_proven, + "closed": same_run_closed_loop_proven, + "reason_code": reason_code, + "cache_fallback_active": strict.get("_cache_fallback_active") is True, + "raw_run_identity_returned": False, + "raw_stage_receipts_returned": False, + } + + def build_iwooos_security_asset_control_plane( *, discovery_row: Any, @@ -696,6 +827,7 @@ def build_iwooos_security_asset_control_plane( ai_trace_row: Any, siem_row: Any, audit_row: Any, + strict_runtime_row: Any = None, source_health: Iterable[dict[str, Any]] | None = None, generated_at: datetime | None = None, ) -> dict[str, Any]: @@ -854,9 +986,10 @@ def build_iwooos_security_asset_control_plane( for row in automation_source if str(_value(row, "status", "")) == "failed" ) - # Aggregate stage receipts cannot prove that every stage belongs to one - # trace. Keep strict runtime completion closed until a same-run join exists. - same_run_closed_loop_proven = False + strict_runtime_completion = _strict_runtime_completion_projection( + strict_runtime_row + ) + same_run_closed_loop_proven = strict_runtime_completion["closed"] is True relationship_count = int(_value(relationship_row, "relationship_count", 0) or 0) orphan_asset_count = int( @@ -1160,7 +1293,9 @@ def build_iwooos_security_asset_control_plane( / len(security_program_domains) ) package_inventory_evidenced = by_type.get("package", 0) > 0 - strict_runtime_closure_percent = 100 if same_run_closed_loop_proven else 0 + strict_runtime_closure_percent = int( + strict_runtime_completion["completion_percent"] + ) completion_dimensions = [ { "dimension_id": "asset_scope", @@ -1280,7 +1415,8 @@ def build_iwooos_security_asset_control_plane( "rollback_receipt_count": rollback_receipts, "failed_operation_count": failed_operations, "same_run_closed_loop_proven": same_run_closed_loop_proven, - "same_run_closed_loop_count": 0, + "same_run_closed_loop_count": 1 if same_run_closed_loop_proven else 0, + "strict_runtime_completion": strict_runtime_completion, }, "security_audit": audit, "supply_chain": { @@ -1361,9 +1497,9 @@ def build_unavailable_iwooos_security_asset_control_plane( "source_status": "live_database_unavailable", "reason_code": reason_code, "summary": { - "source_count": 9, + "source_count": 10, "ready_source_count": 0, - "unavailable_source_count": 9, + "unavailable_source_count": 10, "managed_asset_count": 0, "expected_asset_type_count": len(_ASSET_TYPES), "present_asset_type_count": 0, @@ -1519,6 +1655,7 @@ def build_unavailable_iwooos_security_asset_control_plane( "failed_operation_count": 0, "same_run_closed_loop_proven": False, "same_run_closed_loop_count": 0, + "strict_runtime_completion": _strict_runtime_completion_projection(None), }, "security_audit": { "k8s_execution_audit_count_24h": 0, @@ -1573,6 +1710,7 @@ def build_unavailable_iwooos_security_asset_control_plane( "ai_decision_trace", "siem_runtime", "audit_runtime", + _STRICT_RUNTIME_SOURCE_ID, ) ], "work_items": [ @@ -1669,6 +1807,65 @@ async def _read_public_source( } +async def _read_strict_runtime_source() -> tuple[dict[str, Any], dict[str, Any]]: + """Read and project the canonical same-run receipt contract independently.""" + + try: + readback = await asyncio.wait_for( + load_ai_agent_autonomous_runtime_receipt_readback( + project_id="awoooi", + lookback_hours=_WINDOW_HOURS, + ), + timeout=_STRICT_RUNTIME_SOURCE_TIMEOUT_SECONDS, + ) + db_read_status = str(readback.get("db_read_status") or "") + if db_read_status not in {"ok", "partial"}: + return {}, { + "source_id": _STRICT_RUNTIME_SOURCE_ID, + "status": "unavailable", + "reason_code": "autonomous_strict_runtime_readback_unavailable", + } + strict_runtime = build_ai_agent_strict_runtime_completion_from_readback( + readback + ) + if ( + strict_runtime.get("schema_version") + != _STRICT_RUNTIME_SCHEMA_VERSION + or strict_runtime.get("runtime_contract_schema_version") + != AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION + ): + return {}, { + "source_id": _STRICT_RUNTIME_SOURCE_ID, + "status": "unavailable", + "reason_code": "autonomous_strict_runtime_contract_mismatch", + } + strict_runtime["_cache_fallback_active"] = ( + readback.get("cache_fallback_active") is True + ) + return strict_runtime, { + "source_id": _STRICT_RUNTIME_SOURCE_ID, + "status": "ready", + "reason_code": None, + } + except TimeoutError: + logger.warning("iwooos_security_asset_control_plane_strict_runtime_timeout") + return {}, { + "source_id": _STRICT_RUNTIME_SOURCE_ID, + "status": "unavailable", + "reason_code": "autonomous_strict_runtime_query_timeout", + } + except Exception as exc: # noqa: BLE001 - redact public source failures + logger.warning( + "iwooos_security_asset_control_plane_strict_runtime_unavailable", + error_type=type(exc).__name__, + ) + return {}, { + "source_id": _STRICT_RUNTIME_SOURCE_ID, + "status": "unavailable", + "reason_code": "autonomous_strict_runtime_query_failed", + } + + class IwoooSSecurityAssetControlPlaneService: """Read aggregate production security state with a bounded DB timeout.""" @@ -2057,6 +2254,7 @@ class IwoooSSecurityAssetControlPlaneService: result_mode="one", default={}, ), + _read_strict_runtime_source(), ) ( @@ -2069,6 +2267,7 @@ class IwoooSSecurityAssetControlPlaneService: (ai_trace_row, ai_trace_health), (siem_row, siem_health), (audit_row, audit_health), + (strict_runtime_row, strict_runtime_health), ) = source_results source_health = [ discovery_health, @@ -2080,6 +2279,7 @@ class IwoooSSecurityAssetControlPlaneService: ai_trace_health, siem_health, audit_health, + strict_runtime_health, ] return build_iwooos_security_asset_control_plane( @@ -2092,6 +2292,7 @@ class IwoooSSecurityAssetControlPlaneService: ai_trace_row=ai_trace_row, siem_row=siem_row, audit_row=audit_row, + strict_runtime_row=strict_runtime_row, source_health=source_health, ) diff --git a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py index 7f886bc91..d206b8041 100644 --- a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py +++ b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py @@ -24,6 +24,7 @@ from src.services.awooop_ansible_audit_service import get_ansible_catalog_item SCHEMA_VERSION = "iwooos_wazuh_controlled_executor_runtime_readback_v1" CATALOG_ID = "ansible:wazuh-manager-posture-readback" +INGRESS_CATALOG_ID = "ansible:wazuh-alertmanager-integration" WORK_ITEM_ID = "P0-03-WAZUH-MANAGER-POSTURE" _LIVE_RUNTIME_SQL = """ @@ -36,6 +37,7 @@ _LIVE_RUNTIME_SQL = """ input ->> 'run_id' AS run_id, input ->> 'work_item_id' AS work_item_id, input ->> 'source_receipt_ref' AS source_receipt_ref, + input -> 'executor_candidates' -> 0 ->> 'catalog_id' AS catalog_id, jsonb_array_length( coalesce(input -> 'asset_scope_aliases', '[]'::jsonb) ) AS asset_scope_alias_count, @@ -199,14 +201,20 @@ async def load_latest_iwooos_wazuh_controlled_executor_runtime( try: async with get_db_context(project_id) as db: - result = await db.execute( - text(_LIVE_RUNTIME_SQL), - { - "catalog_match": json.dumps([{"catalog_id": CATALOG_ID}]), - "project_id": project_id, - }, - ) - row = result.mappings().first() + row = None + for catalog_id in (INGRESS_CATALOG_ID, CATALOG_ID): + result = await db.execute( + text(_LIVE_RUNTIME_SQL), + { + "catalog_match": json.dumps( + [{"catalog_id": catalog_id}] + ), + "project_id": project_id, + }, + ) + row = result.mappings().first() + if row: + break except Exception as exc: return build_iwooos_wazuh_controlled_executor_runtime_readback( None, @@ -229,7 +237,9 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( if executor_enabled is None else executor_enabled ) - catalog_ready = get_ansible_catalog_item(CATALOG_ID) is not None + selected_catalog_id = str(data.get("catalog_id") or CATALOG_ID) + ingress_mode = selected_catalog_id == INGRESS_CATALOG_ID + catalog_ready = get_ansible_catalog_item(selected_catalog_id) is not None candidate_present = bool(data.get("candidate_op_id")) check_passed = ( data.get("check_status") == "success" @@ -295,15 +305,15 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( if read_error_type: active_blockers.append(f"runtime_receipt_read_unavailable:{read_error_type}") if not enabled: - active_blockers.append("wazuh_manager_posture_executor_disabled") + active_blockers.append("wazuh_controlled_executor_disabled") if not catalog_ready: - active_blockers.append("wazuh_manager_posture_catalog_missing") + active_blockers.append("wazuh_controlled_catalog_missing") if candidate_present and data.get("check_status") == "failed": - active_blockers.append("wazuh_manager_posture_check_mode_failed") + active_blockers.append("wazuh_controlled_check_mode_failed") if check_failure_class: active_blockers.append(check_failure_class) if apply_failed: - active_blockers.append("wazuh_manager_posture_controlled_probe_failed") + active_blockers.append("wazuh_controlled_apply_failed") if apply_passed and not verifier_passed: active_blockers.append("independent_post_verifier_missing_or_failed") if verifier_passed and not (km_ready and rag_ready and trust_ready): @@ -316,6 +326,7 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( return { "schema_version": SCHEMA_VERSION, "status": _status( + ingress_mode=ingress_mode, read_error_type=read_error_type, candidate_present=candidate_present, check_passed=check_passed, @@ -335,12 +346,18 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( "same_run_correlation_required": True, }, "summary": { + "selected_catalog_id": selected_catalog_id, "executor_policy_enabled_count": 1 if enabled and catalog_ready else 0, "dispatch_candidate_count": 1 if candidate_present else 0, "check_mode_passed_count": 1 if check_passed else 0, "check_mode_failed_count": 1 if check_failed else 0, "runtime_execution_performed_count": 1 if apply_terminal else 0, - "bounded_posture_execution_passed_count": 1 if apply_passed else 0, + "bounded_posture_execution_passed_count": ( + 1 if apply_passed and not ingress_mode else 0 + ), + "bounded_ingress_convergence_passed_count": ( + 1 if apply_passed and ingress_mode else 0 + ), "independent_post_verifier_passed_count": 1 if verifier_passed else 0, "auto_repair_execution_receipt_count": 1 if auto_repair_ready else 0, "km_writeback_count": 1 if km_ready else 0, @@ -357,7 +374,9 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( "same_run_missing_stage_count": len(missing_stage_ids), "same_run_completion_percent": completion_percent, "runtime_closed_count": 1 if runtime_closed else 0, - "host_write_performed_count": 0, + "host_write_performed_count": ( + 1 if apply_terminal and ingress_mode else 0 + ), "wazuh_active_response_performed_count": 0, "secret_value_collection_count": 0, }, @@ -371,6 +390,7 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( "missing_stage_ids": missing_stage_ids, "check_failure_class": check_failure_class, "next_safe_action": _next_action( + ingress_mode=ingress_mode, candidate_present=candidate_present, check_passed=check_passed, check_failed=check_failed, @@ -385,8 +405,12 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( "latest_receipt_at": _latest_receipt_at(data), "boundaries": { "runtime_execution_performed": apply_terminal, - "bounded_no_write_posture_probe": True, - "host_write_performed": False, + "bounded_no_write_posture_probe": not ingress_mode, + "bounded_alert_ingress_convergence": ingress_mode, + "sanitized_alert_ingress_configured": ( + ingress_mode and verifier_passed + ), + "host_write_performed": apply_terminal and ingress_mode, "wazuh_active_response_performed": False, "live_wazuh_api_query_performed": False, "raw_wazuh_payload_persisted": False, @@ -397,6 +421,8 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( }, "source_refs": [ "infra/ansible/playbooks/wazuh-manager-posture-readback.yml", + "infra/ansible/playbooks/wazuh-alertmanager-integration.yml", + "infra/ansible/files/wazuh/custom-awoooi-alertmanager.py", "apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py", "apps/api/src/services/awooop_ansible_check_mode_service.py", "apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py", @@ -406,6 +432,7 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( def _status( *, + ingress_mode: bool, read_error_type: str | None, candidate_present: bool, check_passed: bool, @@ -414,25 +441,27 @@ def _status( apply_passed: bool, runtime_closed: bool, ) -> str: + prefix = "wazuh_alert_ingress" if ingress_mode else "wazuh_manager_posture" if read_error_type: return "degraded_runtime_receipt_read_unavailable" if runtime_closed: - return "wazuh_manager_posture_same_run_runtime_closed" + return f"{prefix}_same_run_runtime_closed" if apply_terminal and not apply_passed: - return "wazuh_manager_posture_execution_failed_waiting_retry_or_repair" + return f"{prefix}_execution_failed_waiting_retry_or_repair" if apply_passed: - return "wazuh_manager_posture_executed_runtime_writeback_open" + return f"{prefix}_executed_runtime_writeback_open" if check_passed: - return "wazuh_manager_posture_check_passed_waiting_bounded_execution" + return f"{prefix}_check_passed_waiting_bounded_execution" if check_failed: - return "wazuh_manager_posture_check_failed_waiting_bounded_retry" + return f"{prefix}_check_failed_waiting_bounded_retry" if candidate_present: - return "wazuh_manager_posture_dispatched_waiting_check_mode" - return "waiting_for_scheduled_wazuh_manager_posture_dispatch" + return f"{prefix}_dispatched_waiting_check_mode" + return f"waiting_for_scheduled_{prefix}_dispatch" def _next_action( *, + ingress_mode: bool, candidate_present: bool, check_passed: bool, check_failed: bool, @@ -444,9 +473,17 @@ def _next_action( missing_stage_ids: list[str], ) -> str: if runtime_closed: - return "keep_scheduled_wazuh_manager_posture_runtime_receipts_fresh" + return ( + "keep_scheduled_wazuh_alert_ingress_runtime_receipts_fresh" + if ingress_mode + else "keep_scheduled_wazuh_manager_posture_runtime_receipts_fresh" + ) if not candidate_present: - return "internal_worker_enqueue_wazuh_manager_posture_candidate" + return ( + "internal_worker_enqueue_wazuh_alert_ingress_candidate" + if ingress_mode + else "internal_worker_enqueue_wazuh_manager_posture_candidate" + ) if check_failed: return ( "repair_and_enqueue_bounded_retry:" @@ -455,7 +492,11 @@ def _next_action( if not check_passed: return "worker_claim_and_run_allowlisted_ansible_check_mode" if not apply_terminal: - return "run_bounded_no_write_wazuh_manager_posture_execution" + return ( + "run_bounded_wazuh_alert_ingress_convergence" + if ingress_mode + else "run_bounded_no_write_wazuh_manager_posture_execution" + ) if not apply_passed: return "classify_transport_or_manager_failure_then_bounded_retry" if not verifier_passed: diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index 6b2e00233..0d5cf28fc 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -16,6 +16,7 @@ from src.services.ai_agent_autonomous_runtime_control import ( _RUNTIME_TIMELINE_COUNTS_SQL, _runtime_stage_receipt_has_required_proof, build_ai_agent_autonomous_runtime_control, + build_ai_agent_strict_runtime_completion_from_readback, build_runtime_receipt_readback_from_rows, classify_deploy_control_plane_observation, ) @@ -1882,6 +1883,9 @@ def test_ai_agent_autonomous_runtime_control_uses_current_owner_directive(): assert strict["metadata_only_counts_as_completion"] is False assert strict["historical_aggregate_fallback_allowed"] is False assert len(strict["stage_contracts"]) == len(AI_AUTOMATION_REQUIRED_LOOP_STAGES) + assert build_ai_agent_strict_runtime_completion_from_readback( + data["runtime_receipt_readback"] + ) == strict assert data["current_policy"]["low_risk_controlled_apply_allowed"] is True assert data["current_policy"]["medium_risk_controlled_apply_allowed"] is True assert data["current_policy"]["high_risk_controlled_apply_allowed"] is True diff --git a/apps/api/tests/test_executor_trust_boundary_readback.py b/apps/api/tests/test_executor_trust_boundary_readback.py index d8ac3faae..ab097a745 100644 --- a/apps/api/tests/test_executor_trust_boundary_readback.py +++ b/apps/api/tests/test_executor_trust_boundary_readback.py @@ -349,8 +349,8 @@ def test_executor_trust_boundary_requires_broker_secret_isolation() -> None: def test_executor_trust_boundary_requires_live_connection_headroom() -> None: receipt = _verified_receipt() - receipt["worker_active_role_connection_count"] = "7" - receipt["worker_available_connection_headroom"] = "1" + receipt["worker_active_role_connection_count"] = "8" + receipt["worker_available_connection_headroom"] = "0" readback = build_executor_trust_boundary_readback( receipt, @@ -382,6 +382,24 @@ def test_executor_trust_boundary_requires_api_null_pool_concurrency_cap() -> Non ) +def test_executor_trust_boundary_does_not_double_count_used_budget() -> None: + receipt = _verified_receipt() + receipt["worker_active_role_connection_count"] = "3" + receipt["worker_available_connection_headroom"] = "5" + + readback = build_executor_trust_boundary_readback( + receipt, + deployed_source_sha="abc123", + ) + + database_boundary = readback["database_identity_boundary"] + assert readback["full_workload_boundary_verified"] is True + assert database_boundary["connection_budget_verified"] is True + assert "worker_connection_budget_not_verified" not in ( + database_boundary["active_blockers"] + ) + + def test_executor_trust_boundary_binds_budget_and_verifier_contract() -> None: wrong_budget = _verified_receipt() wrong_budget["worker_required_connection_budget"] = "4" diff --git a/apps/api/tests/test_iwooos_security_asset_control_plane.py b/apps/api/tests/test_iwooos_security_asset_control_plane.py index 9e724ed12..12fa999fa 100644 --- a/apps/api/tests/test_iwooos_security_asset_control_plane.py +++ b/apps/api/tests/test_iwooos_security_asset_control_plane.py @@ -3,6 +3,7 @@ from __future__ import annotations # ruff: noqa: E402, I001 import asyncio +import copy import json import os from datetime import UTC, datetime, timedelta @@ -20,12 +21,51 @@ from src.services.iwooos_security_asset_control_plane import ( build_iwooos_security_asset_control_plane, build_unavailable_iwooos_security_asset_control_plane, ) +from src.services.ai_automation_runtime_contract import ( + AI_AUTOMATION_REQUIRED_LOOP_STAGES, + AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION, +) def _row(**values): return SimpleNamespace(**values) +def _closed_strict_runtime() -> dict: + return { + "schema_version": "ai_agent_strict_runtime_completion_v2", + "runtime_contract_schema_version": ( + AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION + ), + "completion_percent": 100, + "required_stage_count": len(AI_AUTOMATION_REQUIRED_LOOP_STAGES), + "present_stage_count": len(AI_AUTOMATION_REQUIRED_LOOP_STAGES), + "missing_stage_ids": [], + "latest_flow_closed": True, + "latest_loop_closed": True, + "execution_loop_closed": True, + "same_run_correlation": True, + "full_trace_same_run_correlation_proven": True, + "automation_run_id": "internal-run-identity", + "run_id_mismatch_stage_ids": [], + "uncorrelated_stage_ids": [], + "stage_contracts": [ + { + "stage_id": stage_id, + "evidence_present": True, + "same_run_correlation_proven": True, + "completion_eligible": True, + "evidence_source": "same_run_runtime_stage_receipt", + } + for stage_id in AI_AUTOMATION_REQUIRED_LOOP_STAGES + ], + "closed": True, + "same_run_correlation_required": True, + "metadata_only_counts_as_completion": False, + "historical_aggregate_fallback_allowed": False, + } + + def _ready_payload( source_health: list[dict] | None = None, *, @@ -33,6 +73,7 @@ def _ready_payload( discovery_error: str | None = None, automation_rows: list[SimpleNamespace] | None = None, runtime_receipt_counts: dict[str, int] | None = None, + strict_runtime: dict | None = None, ) -> dict: now = datetime(2026, 7, 11, 4, 0, tzinfo=UTC) discovery = _row( @@ -124,6 +165,7 @@ def _ready_payload( learning_writeback_count_24h=runtime_counts.get("learn", 0), mttr_minutes_24h=14.25, ), + strict_runtime_row=strict_runtime, audit_row=_row( k8s_audit_count_24h=2, k8s_audit_failure_count_24h=0, @@ -249,6 +291,92 @@ def test_projects_canonical_runtime_receipts_without_false_same_run_closure() -> assert work_items["AIA-P0-008-01"]["gap_count"] == 1 +def test_projects_exact_authoritative_same_run_runtime_closure() -> None: + payload = _ready_payload(strict_runtime=_closed_strict_runtime()) + + strict = payload["ai_automation"]["strict_runtime_completion"] + assert strict == { + "schema_version": "ai_agent_strict_runtime_completion_v2", + "runtime_contract_schema_version": ( + AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION + ), + "contract_compatible": True, + "completion_percent": 100, + "required_stage_count": len(AI_AUTOMATION_REQUIRED_LOOP_STAGES), + "present_stage_count": len(AI_AUTOMATION_REQUIRED_LOOP_STAGES), + "missing_stage_count": 0, + "same_run_correlation": True, + "full_trace_same_run_correlation_proven": True, + "execution_loop_closed": True, + "closed": True, + "reason_code": None, + "cache_fallback_active": False, + "raw_run_identity_returned": False, + "raw_stage_receipts_returned": False, + } + assert payload["completion"]["strict_runtime_closure_percent"] == 100 + assert payload["ai_automation"]["same_run_closed_loop_proven"] is True + assert payload["ai_automation"]["same_run_closed_loop_count"] == 1 + assert all( + item["work_item_id"] != "AIA-P0-008-01" + for item in payload["work_items"] + ) + assert payload["completion"]["overall_percent"] == round( + sum( + dimension["completion_percent"] + for dimension in payload["completion"]["dimensions"] + ) + / payload["completion"]["dimension_count"] + ) + + public_text = json.dumps(payload, ensure_ascii=False) + assert "internal-run-identity" not in public_text + assert '"automation_run_id"' not in public_text + assert '"stage_contracts"' not in public_text + + +def test_strict_runtime_projection_fails_closed_on_contract_or_receipt_drift() -> None: + variants: list[dict] = [] + + bad_schema = _closed_strict_runtime() + bad_schema["schema_version"] = "ai_agent_strict_runtime_completion_v1" + variants.append(bad_schema) + + missing_stage = _closed_strict_runtime() + missing_stage["present_stage_count"] -= 1 + missing_stage["missing_stage_ids"] = [AI_AUTOMATION_REQUIRED_LOOP_STAGES[-1]] + variants.append(missing_stage) + + cross_run = _closed_strict_runtime() + cross_run["same_run_correlation"] = False + cross_run["run_id_mismatch_stage_ids"] = ["telegram_receipt"] + variants.append(cross_run) + + no_run_identity = _closed_strict_runtime() + no_run_identity["automation_run_id"] = "" + variants.append(no_run_identity) + + metadata_only = _closed_strict_runtime() + metadata_only["metadata_only_counts_as_completion"] = True + variants.append(metadata_only) + + invalid_stage_contract = copy.deepcopy(_closed_strict_runtime()) + invalid_stage_contract["stage_contracts"][0]["completion_eligible"] = False + variants.append(invalid_stage_contract) + + for strict_runtime in variants: + payload = _ready_payload(strict_runtime=strict_runtime) + strict = payload["ai_automation"]["strict_runtime_completion"] + assert strict["closed"] is False + assert strict["completion_percent"] == 0 + assert payload["completion"]["strict_runtime_closure_percent"] == 0 + assert payload["ai_automation"]["same_run_closed_loop_proven"] is False + assert any( + item["work_item_id"] == "AIA-P0-008-01" + for item in payload["work_items"] + ) + + def test_discovery_failure_exposes_only_allowlisted_collector_ids() -> None: raw_error = ( "RuntimeError: asset_collector_failures=domain_tls_inventory," @@ -612,6 +740,9 @@ def test_unavailable_payload_is_fixed_degraded_contract_without_raw_error() -> N assert payload["summary"]["overall_security_completion_percent"] == 0 assert payload["completion"]["overall_percent"] == 0 assert payload["completion"]["strict_runtime_closure_percent"] == 0 + assert payload["summary"]["source_count"] == 10 + assert payload["summary"]["unavailable_source_count"] == 10 + assert payload["ai_automation"]["strict_runtime_completion"]["closed"] is False assert payload["discovery"]["fresh"] is False assert payload["boundaries"]["raw_asset_identity_returned"] is False assert payload["boundaries"]["raw_event_payload_returned"] is False @@ -676,12 +807,25 @@ def test_service_uses_canonical_awoooi_project_scope(monkeypatch) -> None: "src.services.iwooos_security_asset_control_plane.get_db_context", fake_db_context, ) + + async def strict_runtime_unavailable(): + return {}, { + "source_id": "autonomous_strict_runtime", + "status": "unavailable", + "reason_code": "autonomous_strict_runtime_query_failed", + } + + monkeypatch.setattr( + "src.services.iwooos_security_asset_control_plane._read_strict_runtime_source", + strict_runtime_unavailable, + ) payload = asyncio.run(service.get_snapshot()) assert requested_projects == ["awoooi"] * 9 assert payload["status"] == "degraded" assert payload["source_status"] == "live_database_unavailable" - assert payload["summary"]["unavailable_source_count"] == 9 + assert payload["summary"]["source_count"] == 10 + assert payload["summary"]["unavailable_source_count"] == 10 def test_service_bounds_source_concurrency_for_database_budget(monkeypatch) -> None: @@ -719,11 +863,23 @@ def test_service_bounds_source_concurrency_for_database_budget(monkeypatch) -> N "src.services.iwooos_security_asset_control_plane.get_db_context", lambda project_id: DbContext(), ) + + async def strict_runtime_ready(): + return _closed_strict_runtime(), { + "source_id": "autonomous_strict_runtime", + "status": "ready", + "reason_code": None, + } + + monkeypatch.setattr( + "src.services.iwooos_security_asset_control_plane._read_strict_runtime_source", + strict_runtime_ready, + ) payload = asyncio.run(service._load_snapshot()) assert concurrency["maximum"] == 3 - assert payload["summary"]["source_count"] == 9 - assert payload["summary"]["ready_source_count"] == 9 + assert payload["summary"]["source_count"] == 10 + assert payload["summary"]["ready_source_count"] == 10 assert payload["summary"]["unavailable_source_count"] == 0 compliance_query = next( statement diff --git a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py index 19027bc93..ef49b528a 100644 --- a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py +++ b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py @@ -1,5 +1,7 @@ from __future__ import annotations +# ruff: noqa: E402, I001 + import os from contextlib import asynccontextmanager from datetime import UTC, datetime @@ -90,6 +92,30 @@ def test_wazuh_runtime_readback_closes_only_with_complete_same_run_receipts() -> assert payload["boundaries"]["command_output_returned"] is False +def test_wazuh_runtime_readback_prefers_ingress_convergence_semantics() -> None: + row = _complete_row() + row["catalog_id"] = "ansible:wazuh-alertmanager-integration" + row["work_item_id"] = "P0-03-WAZUH-ALERT-INGRESS" + + payload = build_iwooos_wazuh_controlled_executor_runtime_readback( + row, + executor_enabled=True, + ) + + assert payload["status"] == "wazuh_alert_ingress_same_run_runtime_closed" + assert payload["work_item_id"] == "P0-03-WAZUH-ALERT-INGRESS" + assert payload["summary"]["selected_catalog_id"] == ( + "ansible:wazuh-alertmanager-integration" + ) + assert payload["summary"]["bounded_posture_execution_passed_count"] == 0 + assert payload["summary"]["bounded_ingress_convergence_passed_count"] == 1 + assert payload["summary"]["host_write_performed_count"] == 1 + assert payload["boundaries"]["bounded_no_write_posture_probe"] is False + assert payload["boundaries"]["bounded_alert_ingress_convergence"] is True + assert payload["boundaries"]["sanitized_alert_ingress_configured"] is True + assert payload["boundaries"]["raw_wazuh_payload_persisted"] is False + + def test_wazuh_runtime_readback_keeps_partial_execution_open() -> None: row = _complete_row() row["stage_ids"] = [ diff --git a/apps/api/tests/test_wazuh_alertmanager_integration.py b/apps/api/tests/test_wazuh_alertmanager_integration.py new file mode 100644 index 000000000..c1cef7f58 --- /dev/null +++ b/apps/api/tests/test_wazuh_alertmanager_integration.py @@ -0,0 +1,418 @@ +from __future__ import annotations + +# ruff: noqa: E402, I001 + +import importlib.util +import json +import os +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +import yaml + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +from src.services.awooop_ansible_audit_service import ( + _catalog_hints, + get_ansible_catalog_item, +) +from src.services.awooop_ansible_post_verifier import postconditions_for_catalog +from src.services.awooop_ansible_check_mode_service import ( + build_ansible_apply_command, + build_ansible_check_mode_command, +) +from src.core.config import settings +from src.services.controlled_alert_target_router import resolve_typed_alert_target +from src.services.incident_service import classify_alert_early + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ( + ROOT + / "infra" + / "ansible" + / "files" + / "wazuh" + / "custom-awoooi-alertmanager.py" +) +PLAYBOOK = ( + ROOT + / "infra" + / "ansible" + / "playbooks" + / "wazuh-alertmanager-integration.yml" +) +BROKER_DEPLOYMENT = ( + ROOT / "k8s" / "awoooi-prod" / "08-deployment-ansible-executor-broker.yaml" +) +WORKER_DEPLOYMENT = ROOT / "k8s" / "awoooi-prod" / "08-deployment-worker.yaml" +CATALOG_ID = "ansible:wazuh-alertmanager-integration" + + +def _load_bridge_module(): + spec = importlib.util.spec_from_file_location("wazuh_awoooi_bridge", SCRIPT) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _alert(*, agent_id: str = "017") -> dict: + return { + "timestamp": "2026-07-15T05:30:00Z", + "manager": {"name": "wazuh-manager-internal"}, + "agent": { + "id": agent_id, + "name": "private-endpoint-name", + "ip": "192.168.0.55", + }, + "rule": { + "id": "5710", + "level": 12, + "description": "private security evidence", + "groups": ["authentication_failures", "sshd"], + "mitre": {"id": ["T1110", "not-a-technique"]}, + }, + "full_log": "raw source event must remain in Wazuh", + "data": {"srcip": "203.0.113.7"}, + } + + +def test_wazuh_bridge_emits_only_public_safe_stable_metadata() -> None: + bridge = _load_bridge_module() + + payload = bridge.build_alertmanager_payload( + _alert(), + now=datetime(2026, 7, 15, 5, 30, tzinfo=UTC), + ) + encoded = json.dumps(payload, sort_keys=True) + labels = payload["alerts"][0]["labels"] + + assert labels["alertname"] == "WazuhSecurityEvent_5710" + assert labels["component"] == "wazuh-manager" + assert labels["severity"] == "critical" + assert labels["wazuh_rule_groups"] == "authentication_failures,sshd" + assert labels["mitre_techniques"] == "T1110" + assert labels["deployment"].startswith("wazuh-agent-") + assert labels["raw_payload_persisted"] == "false" + assert labels["active_response_requested"] == "false" + for forbidden in ( + "private-endpoint-name", + "192.168.0.55", + "203.0.113.7", + "private security evidence", + "raw source event must remain in Wazuh", + "wazuh-manager-internal", + ): + assert forbidden not in encoded + + +def test_wazuh_bridge_fingerprint_clusters_same_rule_and_agent() -> None: + bridge = _load_bridge_module() + + first = bridge.build_alertmanager_payload(_alert(agent_id="017")) + second = bridge.build_alertmanager_payload(_alert(agent_id="017")) + other_agent = bridge.build_alertmanager_payload(_alert(agent_id="018")) + + assert first["groupKey"] == second["groupKey"] + assert first["groupKey"] != other_agent["groupKey"] + assert ( + first["alerts"][0]["labels"]["deployment"] + != other_agent["alerts"][0]["labels"]["deployment"] + ) + + +@pytest.mark.parametrize( + "url", + [ + "http://awoooi.wooo.work/api/v1/webhooks/alertmanager", + "https://example.com/api/v1/webhooks/alertmanager", + "https://awoooi.wooo.work/api/v1/webhooks/alertmanager?raw=1", + "https://user:pass@awoooi.wooo.work/api/v1/webhooks/alertmanager", + ], +) +def test_wazuh_bridge_rejects_non_allowlisted_hook_urls(url: str) -> None: + bridge = _load_bridge_module() + + with pytest.raises(ValueError, match="hook_url_not_allowlisted"): + bridge._validated_hook_url(url) + + +def test_wazuh_integration_catalog_and_rollback_contract_are_bounded() -> None: + catalog = get_ansible_catalog_item(CATALOG_ID) + playbook = yaml.safe_load(PLAYBOOK.read_text(encoding="utf-8")) + text = PLAYBOOK.read_text(encoding="utf-8") + conditions = postconditions_for_catalog(CATALOG_ID) + + assert catalog is not None + assert catalog["inventory_hosts"] == ["host_112"] + assert catalog["risk_level"] == "high" + assert catalog["auto_apply_enabled"] is True + assert catalog["approval_required"] is False + assert catalog["supports_check_mode"] is True + assert playbook[0]["hosts"] == "host_112" + assert "backup: true" in text + assert "remote_src: true" in text + assert "wazuh_privileged_convergence_capability_missing" in text + assert "/var/lib/awoooi-wazuh-alert-ingress/receipt.env" in text + assert "raw_payload_persisted=0" in text + assert "active_response_enabled=0" in text + assert "wazuh_alertmanager_integration_rolled_back" in text + assert "slurp:" not in text + assert "api_key>" not in text + assert "ansible_become_pass" not in text + assert "raw.githubusercontent.com" not in text + assert {condition.condition_id for condition in conditions} == { + "host_112_wazuh_alertmanager_script", + "host_112_wazuh_alertmanager_config", + "host_112_wazuh_manager_runtime_after_ingress_convergence", + "host_112_wazuh_integrator_runtime", + "host_112_awoooi_alertmanager_ingress_reachable", + } + + +def test_wazuh_become_password_projection_is_path_only_and_optional( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + password_file = tmp_path / "become-password" + password_file.write_text("test-only-secret-value\n", encoding="utf-8") + monkeypatch.setattr( + settings, + "AWOOOP_ANSIBLE_BECOME_PASSWORD_FILE", + str(password_file), + ) + + spec = build_ansible_check_mode_command( + playbook_path="infra/ansible/playbooks/wazuh-alertmanager-integration.yml", + inventory_hosts=("host_112",), + playbook_root=ROOT / "infra" / "ansible", + check_mode_ssh_key_path=tmp_path / "ssh-key", + check_mode_known_hosts_path=tmp_path / "known-hosts", + ) + manifest = yaml.safe_load(BROKER_DEPLOYMENT.read_text(encoding="utf-8")) + volume = next( + item + for item in manifest["spec"]["template"]["spec"]["volumes"] + if item["name"] == "host112-become-password" + ) + + assert spec.env["ANSIBLE_BECOME_PASSWORD_FILE"] == str(password_file) + assert "test-only-secret-value" not in " ".join(spec.command) + assert volume["secret"]["optional"] is True + assert volume["secret"]["items"] == [ + {"key": "HOST112_ANSIBLE_BECOME_PASSWORD", "path": "password"} + ] + assert "test-only-secret-value" not in BROKER_DEPLOYMENT.read_text( + encoding="utf-8" + ) + + apply_spec = build_ansible_apply_command( + playbook_path="infra/ansible/playbooks/wazuh-alertmanager-integration.yml", + inventory_hosts=("host_112",), + playbook_root=ROOT / "infra" / "ansible", + check_mode_ssh_key_path=tmp_path / "ssh-key", + check_mode_known_hosts_path=tmp_path / "known-hosts", + ) + assert apply_spec.env["ANSIBLE_BECOME_PASSWORD_FILE"] == str(password_file) + assert "test-only-secret-value" not in " ".join(apply_spec.command) + + unrelated_spec = build_ansible_check_mode_command( + playbook_path="infra/ansible/playbooks/wazuh-manager-posture-readback.yml", + inventory_hosts=("host_112",), + playbook_root=ROOT / "infra" / "ansible", + check_mode_ssh_key_path=tmp_path / "ssh-key", + check_mode_known_hosts_path=tmp_path / "known-hosts", + ) + assert "ANSIBLE_BECOME_PASSWORD_FILE" not in unrelated_spec.env + + +def test_wazuh_ingress_convergence_uses_only_the_mutating_catalog() -> None: + route = resolve_typed_alert_target( + alertname="WazuhAlertmanagerIntegrationConvergence", + target_resource="wazuh-manager", + namespace="awoooi", + labels={"service": "wazuh-manager", "tool": "wazuh"}, + ) + hints = _catalog_hints( + { + "incident_id": "IWZ-INGRESS-2026071500", + "alertname": "WazuhAlertmanagerIntegrationConvergence", + "alert_category": "security", + "affected_services": ["wazuh-manager", "siem"], + "signals": [ + { + "alert_name": "WazuhAlertmanagerIntegrationConvergence", + "labels": { + "service": "wazuh-manager", + "component": "wazuh-manager", + "tool": "wazuh", + }, + } + ], + }, + None, + ) + + assert route["canonical_asset_id"] == "service:wazuh-manager" + assert route["allowed_catalog_ids"] == [CATALOG_ID] + assert [item["catalog_id"] for item in hints["candidates"]] == [CATALOG_ID] + + +@pytest.mark.asyncio +async def test_wazuh_ingress_scheduler_records_one_high_risk_controlled_candidate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.jobs import awooop_ansible_candidate_backfill_job as job + + class _Mappings: + def first(self): + return None + + class _Result: + def mappings(self): + return _Mappings() + + class _Db: + async def execute(self, _statement, params): + assert json.loads(params["catalog_match"]) == [ + {"catalog_id": CATALOG_ID} + ] + return _Result() + + @asynccontextmanager + async def fake_db_context(project_id: str): + assert project_id == "awoooi" + yield _Db() + + recorder = AsyncMock(return_value=True) + monkeypatch.setattr(job, "get_db_context", fake_db_context) + monkeypatch.setattr( + job.settings, + "ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR", + True, + ) + monkeypatch.setattr( + job.settings, + "IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS", + 6, + ) + monkeypatch.setattr( + job, + "now_taipei", + MagicMock(return_value=datetime.fromisoformat("2026-07-15T05:59:59+08:00")), + ) + + result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once( + recorder=recorder, + evidence_collector=AsyncMock(return_value=MagicMock(snapshot_id="wazuh-ingress")), + evidence_verifier=AsyncMock(return_value=True), + ) + + assert result["status"] == "queued_for_controlled_executor" + assert result["work_item_id"] == "P0-03-WAZUH-ALERT-INGRESS" + recorded = recorder.await_args.kwargs + assert recorded["incident"]["alertname"] == ( + "WazuhAlertmanagerIntegrationConvergence" + ) + assert recorded["incident"]["incident_id"] == "IWZ-INGRESS-2026071500" + assert len(recorded["incident"]["incident_id"]) <= 30 + assert recorded["proposal_data"]["risk_level"] == "high" + assert recorded["proposal_data"]["execution_priority"] == 0 + assert recorded["incident"]["source_receipt_ref"] == ( + "scheduled-wazuh-alert-ingress:2026071500" + ) + + write_not_acknowledged = ( + await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once( + recorder=AsyncMock(return_value=False), + evidence_collector=AsyncMock( + return_value=MagicMock(snapshot_id="wazuh-ingress") + ), + evidence_verifier=AsyncMock(return_value=True), + ) + ) + assert write_not_acknowledged["status"] == "candidate_write_not_acknowledged" + assert write_not_acknowledged["existing"] == 0 + assert write_not_acknowledged["active_blockers"] == [ + "candidate_write_not_acknowledged" + ] + + +@pytest.mark.asyncio +async def test_wazuh_ingress_scheduler_reports_unresolved_payload_before_recording( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.jobs import awooop_ansible_candidate_backfill_job as job + + class _Mappings: + def first(self): + return None + + class _Result: + def mappings(self): + return _Mappings() + + class _Db: + async def execute(self, _statement, _params): + return _Result() + + @asynccontextmanager + async def fake_db_context(_project_id: str): + yield _Db() + + recorder = AsyncMock(return_value=False) + monkeypatch.setattr(job, "get_db_context", fake_db_context) + monkeypatch.setattr( + job.settings, + "ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR", + True, + ) + monkeypatch.setattr( + job, + "build_ansible_decision_audit_payload", + MagicMock(return_value=None), + ) + + result = await job.enqueue_iwooos_wazuh_alertmanager_integration_candidate_once( + recorder=recorder, + evidence_collector=AsyncMock(return_value=MagicMock(snapshot_id="wazuh-ingress")), + evidence_verifier=AsyncMock(return_value=True), + ) + + assert result["status"] == "blocked_canonical_asset_or_ansible_catalog_unresolved" + assert result["existing"] == 0 + assert result["active_blockers"] == [ + "canonical_asset_or_ansible_catalog_unresolved" + ] + recorder.assert_not_awaited() + + +def test_wazuh_ingress_scheduler_worker_mounts_canonical_service_registry() -> None: + deployment = yaml.safe_load(WORKER_DEPLOYMENT.read_text(encoding="utf-8")) + pod_spec = deployment["spec"]["template"]["spec"] + container = pod_spec["containers"][0] + + mount = next( + item for item in container["volumeMounts"] if item["name"] == "service-registry" + ) + volume = next(item for item in pod_spec["volumes"] if item["name"] == "service-registry") + + assert mount == { + "name": "service-registry", + "mountPath": "/app/ops/config/service-registry.yaml", + "subPath": "service-registry.yaml", + "readOnly": True, + } + assert volume["configMap"]["name"] == "service-registry" + + +def test_wazuh_security_event_uses_secops_lifecycle() -> None: + assert classify_alert_early( + "WazuhSecurityEvent_5710", + "critical", + {"tool": "wazuh"}, + ) == ("secops", "TYPE-5S") diff --git a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx index b12c17689..07b5649e1 100644 --- a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx +++ b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx @@ -36,7 +36,7 @@ type Copy = { assetTypes: string; nist: string; siem: string; - aiLoop: string; + strictRun: string; verified: string; functions: string; framework: string; @@ -70,6 +70,7 @@ type Copy = { failedCollectors: string; failedCollectorsUnavailable: string; sameRunMissing: string; + sameRunVerified: string; loading: string; unavailable: string; refresh: string; @@ -87,7 +88,7 @@ const COPY: Record<"zh" | "en", Copy> = { assetTypes: "資產類型", nist: "NIST 控制", siem: "SIEM 閉環", - aiLoop: "AI 閉環", + strictRun: "同 run 閉環", verified: "24h 已驗證", functions: "治理六功能", framework: "完整資安框架", @@ -121,6 +122,7 @@ const COPY: Record<"zh" | "en", Copy> = { failedCollectors: "失敗 Collector", failedCollectorsUnavailable: "失敗原因待安全分類", sameRunMissing: "尚無同 run 閉環證據", + sameRunVerified: "18 段同 run 閉環已驗證", loading: "正在載入資安控制平面", unavailable: "Live readback 無法取得", refresh: "重新整理資安控制平面", @@ -136,7 +138,7 @@ const COPY: Record<"zh" | "en", Copy> = { assetTypes: "Asset types", nist: "NIST control", siem: "SIEM closure", - aiLoop: "AI closure", + strictRun: "Same-run closure", verified: "Verified 24h", functions: "Six functions", framework: "Security framework", @@ -170,6 +172,7 @@ const COPY: Record<"zh" | "en", Copy> = { failedCollectors: "Failed collectors", failedCollectorsUnavailable: "Failure detail awaiting safe classification", sameRunMissing: "No same-run closure evidence", + sameRunVerified: "18-stage same-run closure verified", loading: "Loading security control plane", unavailable: "Live readback unavailable", refresh: "Refresh security control plane", @@ -557,10 +560,10 @@ export function SecurityAssetControlPlaneCockpit({ icon={Radar} /> {copy.sameRunMissing} - ) : null} + ) : ( +
+
+ )}
diff --git a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts index dfd88c5a3..b660b8b0d 100644 --- a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts +++ b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts @@ -14,8 +14,8 @@ function payload(): SecurityAssetControlPlanePayload { status: "ready", source_status: "live_database_aggregate", summary: { - source_count: 9, - ready_source_count: 9, + source_count: 10, + ready_source_count: 10, unavailable_source_count: 0, managed_asset_count: 24, expected_asset_type_count: 28, @@ -97,6 +97,24 @@ function payload(): SecurityAssetControlPlanePayload { failed_operation_count: 0, same_run_closed_loop_proven: false, same_run_closed_loop_count: 0, + strict_runtime_completion: { + schema_version: "ai_agent_strict_runtime_completion_v2", + runtime_contract_schema_version: + "ai_automation_runtime_completion_contract_v2", + contract_compatible: true, + completion_percent: 0, + required_stage_count: 18, + present_stage_count: 17, + missing_stage_count: 1, + same_run_correlation: false, + full_trace_same_run_correlation_proven: false, + execution_loop_closed: false, + closed: false, + reason_code: "strict_runtime_receipts_incomplete", + cache_fallback_active: false, + raw_run_identity_returned: false, + raw_stage_receipts_returned: false, + }, }, security_audit: { k8s_execution_audit_count_24h: 2, @@ -222,6 +240,20 @@ describe("security asset control plane projection", () => { expect(isSecurityAssetControlPlanePayload(unsafeCollectorIdentity)).toBe( false, ); + + const inconsistentStrictRuntime = { + ...safe, + ai_automation: { + ...safe.ai_automation, + strict_runtime_completion: { + ...safe.ai_automation.strict_runtime_completion, + closed: true, + }, + }, + }; + expect(isSecurityAssetControlPlanePayload(inconsistentStrictRuntime)).toBe( + false, + ); }); it("does not show healthy before same-run closure is proven", () => { diff --git a/apps/web/src/lib/security-asset-control-plane.ts b/apps/web/src/lib/security-asset-control-plane.ts index c6e7b5da9..d80a570d4 100644 --- a/apps/web/src/lib/security-asset-control-plane.ts +++ b/apps/web/src/lib/security-asset-control-plane.ts @@ -119,6 +119,24 @@ export type SecurityControlPlaneCertificateInventory = { private_key_material_read: false; }; +export type SecurityStrictRuntimeCompletion = { + schema_version: "ai_agent_strict_runtime_completion_v2"; + runtime_contract_schema_version: "ai_automation_runtime_completion_contract_v2"; + contract_compatible: boolean; + completion_percent: number; + required_stage_count: number; + present_stage_count: number; + missing_stage_count: number; + same_run_correlation: boolean; + full_trace_same_run_correlation_proven: boolean; + execution_loop_closed: boolean; + closed: boolean; + reason_code: string | null; + cache_fallback_active: boolean; + raw_run_identity_returned: false; + raw_stage_receipts_returned: false; +}; + export type SecurityAssetControlPlanePayload = { schema_version: "iwooos_security_asset_control_plane_v1"; generated_at: string; @@ -181,6 +199,7 @@ export type SecurityAssetControlPlanePayload = { failed_operation_count: number; same_run_closed_loop_proven: boolean; same_run_closed_loop_count: number; + strict_runtime_completion: SecurityStrictRuntimeCompletion; }; security_audit: { k8s_execution_audit_count_24h: number; @@ -255,6 +274,44 @@ export function isSecurityAssetControlPlanePayload( ) { return false; } + const strictRuntime = value.ai_automation.strict_runtime_completion; + if ( + !isRecord(strictRuntime) || + strictRuntime.schema_version !== "ai_agent_strict_runtime_completion_v2" || + strictRuntime.runtime_contract_schema_version !== + "ai_automation_runtime_completion_contract_v2" || + typeof strictRuntime.contract_compatible !== "boolean" || + typeof strictRuntime.completion_percent !== "number" || + typeof strictRuntime.required_stage_count !== "number" || + typeof strictRuntime.present_stage_count !== "number" || + typeof strictRuntime.missing_stage_count !== "number" || + typeof strictRuntime.same_run_correlation !== "boolean" || + typeof strictRuntime.full_trace_same_run_correlation_proven !== "boolean" || + typeof strictRuntime.execution_loop_closed !== "boolean" || + typeof strictRuntime.closed !== "boolean" || + typeof strictRuntime.cache_fallback_active !== "boolean" || + !( + strictRuntime.reason_code === null || + typeof strictRuntime.reason_code === "string" + ) || + strictRuntime.raw_run_identity_returned !== false || + strictRuntime.raw_stage_receipts_returned !== false || + strictRuntime.required_stage_count !== 18 || + strictRuntime.present_stage_count < 0 || + strictRuntime.present_stage_count > strictRuntime.required_stage_count || + strictRuntime.missing_stage_count !== + strictRuntime.required_stage_count - strictRuntime.present_stage_count || + value.ai_automation.same_run_closed_loop_proven !== strictRuntime.closed || + (strictRuntime.closed && + (!strictRuntime.contract_compatible || + strictRuntime.completion_percent !== 100 || + !strictRuntime.same_run_correlation || + !strictRuntime.full_trace_same_run_correlation_proven || + !strictRuntime.execution_loop_closed)) || + (!strictRuntime.closed && strictRuntime.completion_percent !== 0) + ) { + return false; + } if (value.supply_chain !== undefined) { if (!isRecord(value.supply_chain)) return false; if ( diff --git a/config/signoz/metadata-export-policy.json b/config/signoz/metadata-export-policy.json new file mode 100644 index 000000000..d26b0ac77 --- /dev/null +++ b/config/signoz/metadata-export-policy.json @@ -0,0 +1,204 @@ +{ + "schema": "awoooi_signoz_metadata_export_policy_v1", + "policy_version": "2026-07-15", + "work_item_id": "P0-OBS-002", + "source_runtime": { + "canonical_id": "signoz-110-control-plane", + "signoz_version": "v0.113.0", + "minimum_python_version": "3.10", + "raw_database_access_allowed": false, + "raw_volume_access_allowed": false, + "github_supply_chain_allowed": false + }, + "authentication": { + "header_name": "SIGNOZ-API-KEY", + "secret_value_in_arguments_allowed": false, + "secret_reference_type": "root_owned_regular_file", + "accepted_file_modes": [ + "0400", + "0600" + ] + }, + "limits": { + "request_timeout_seconds": 15, + "max_response_bytes": 4194304, + "max_asset_count": 16, + "max_items_per_asset": 1000, + "max_restore_actions": 500, + "stable_read_count": 2 + }, + "forbidden_key_names": [ + "access_key", + "access_token", + "api_key", + "authorization", + "client_secret", + "cookie", + "credential", + "password", + "private_key", + "refresh_token", + "routing_key", + "secret", + "service_account_json", + "session", + "token", + "webhook_url" + ], + "forbidden_value_patterns": [ + "-----BEGIN [A-Z ]*PRIVATE KEY-----", + "(?i)\\bBearer\\s+[A-Za-z0-9._~+/-]{16,}={0,2}\\b", + "(?i)https?://[^/@\\s]+:[^/@\\s]+@" + ], + "forbidden_endpoints": [ + { + "id": "personal_access_tokens", + "path": "/api/v1/pats", + "reason": "response may contain an API token" + }, + { + "id": "authentication_domains", + "path": "/api/v1/domains", + "reason": "response may contain OIDC or Google client secrets" + }, + { + "id": "notification_channels", + "path": "/api/v1/channels", + "reason": "response may contain webhook and routing credentials" + }, + { + "id": "users_and_credentials", + "path": "/api/v1/user", + "reason": "credential lifecycle is reconstructed, not exported" + }, + { + "id": "invitations", + "path": "/api/v1/invite", + "reason": "invitation tokens are non-portable credentials" + }, + { + "id": "sessions", + "path": "/api/v1/sessions", + "reason": "sessions are explicitly outside backup scope" + } + ], + "assets": [ + { + "id": "dashboards", + "endpoint": "/api/v1/dashboards", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "portable", + "restore": { + "method": "POST", + "endpoint": "/api/v1/dashboards", + "strategy": "collection_items", + "strip_top_level_fields": [ + "createdAt", + "id", + "orgId", + "updatedAt", + "uuid" + ] + } + }, + { + "id": "alert_rules", + "endpoint": "/api/v1/rules", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "portable", + "restore": { + "method": "POST", + "endpoint": "/api/v1/rules", + "strategy": "collection_items", + "strip_top_level_fields": [ + "createdAt", + "id", + "orgId", + "updatedAt" + ] + } + }, + { + "id": "explorer_views", + "endpoint": "/api/v1/explorer/views", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "portable", + "restore": { + "method": "POST", + "endpoint": "/api/v1/explorer/views", + "strategy": "collection_items", + "strip_top_level_fields": [ + "createdAt", + "id", + "orgId", + "updatedAt", + "userId" + ] + } + }, + { + "id": "roles", + "endpoint": "/api/v1/roles", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "export_only", + "coverage_gap": "role relation objects require a separately bounded enumerator" + }, + { + "id": "organization_preferences", + "endpoint": "/api/v1/org/preferences", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "export_only", + "coverage_gap": "preference names require a reviewed per-name restore allowlist" + }, + { + "id": "user_preferences", + "endpoint": "/api/v1/user/preferences", + "method": "GET", + "response_pointer": "/data", + "response_kind": "collection", + "classification": "export_only", + "coverage_gap": "principal-scoped preferences are not portable without identity reconstruction" + }, + { + "id": "global_config", + "endpoint": "/api/v1/global/config", + "method": "GET", + "response_pointer": "/data", + "response_kind": "document", + "classification": "export_only", + "coverage_gap": "readback-only configuration has no supported write endpoint" + } + ], + "restore_isolation": { + "target_url_scope": "loopback_only", + "required_image_ref": "signoz/signoz:v0.113.0", + "image_id_sha256_required": true, + "image_pull_allowed": false, + "production_network_attachment_allowed": false, + "production_volume_attachment_allowed": false, + "ephemeral_volumes_only": true, + "cleanup_controller_required": true, + "zero_residue_verifier_required": true, + "resource_label_key": "awoooi.signoz.metadata.restore.run_id" + }, + "completion_contract": { + "portable_export_alone_is_completion": false, + "independent_bundle_verifier_required": true, + "isolated_restore_semantic_readback_required": true, + "zero_residue_terminal_required": true, + "durable_terminal_receipt_required_for_apply": true, + "failure_terminal_receipt_required": true, + "secret_assets_reissue_required": true, + "full_sqlite_completion_claim_allowed": false + } +} diff --git a/infra/ansible/files/wazuh/custom-awoooi-alertmanager.py b/infra/ansible/files/wazuh/custom-awoooi-alertmanager.py new file mode 100644 index 000000000..dc4848cca --- /dev/null +++ b/infra/ansible/files/wazuh/custom-awoooi-alertmanager.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Forward a public-safe Wazuh alert envelope to AWOOOI Alertmanager ingress.""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any +from urllib.error import HTTPError +from urllib.parse import urlsplit +from urllib.request import HTTPRedirectHandler, Request, build_opener + +_ALLOWED_HOST = "awoooi.wooo.work" +_ALLOWED_PATH = "/api/v1/webhooks/alertmanager" +_MAX_ALERT_BYTES = 1024 * 1024 +_SAFE_TOKEN_RE = re.compile(r"[^A-Za-z0-9_.:-]+") +_MITRE_RE = re.compile(r"^T[0-9]{4}(?:\.[0-9]{3})?$") + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001 + return None + + +def _safe_token(value: Any, *, fallback: str, limit: int = 80) -> str: + token = _SAFE_TOKEN_RE.sub("-", str(value or "").strip()).strip("-._:") + return (token or fallback)[:limit] + + +def _rule_groups(rule: dict[str, Any]) -> list[str]: + raw_groups = rule.get("groups") or [] + if isinstance(raw_groups, str): + raw_groups = raw_groups.split(",") + if not isinstance(raw_groups, list): + return [] + groups = { + _safe_token(value, fallback="unknown", limit=48) + for value in raw_groups + if str(value or "").strip() + } + groups.discard("unknown") + return sorted(groups)[:8] + + +def _mitre_techniques(rule: dict[str, Any]) -> list[str]: + mitre = rule.get("mitre") or {} + raw_ids = mitre.get("id") if isinstance(mitre, dict) else [] + if isinstance(raw_ids, str): + raw_ids = [raw_ids] + if not isinstance(raw_ids, list): + return [] + return sorted({str(value).strip() for value in raw_ids if _MITRE_RE.fullmatch(str(value).strip())})[:8] + + +def _agent_alias(alert: dict[str, Any]) -> str: + manager = alert.get("manager") or {} + agent = alert.get("agent") or {} + manager_identity = str(manager.get("name") or "primary-manager") + agent_identity = str(agent.get("id") or agent.get("name") or "manager-local") + digest = hashlib.sha256( + f"awoooi-wazuh-agent-v1\0{manager_identity}\0{agent_identity}".encode() + ).hexdigest()[:16] + return f"wazuh-agent-{digest}" + + +def _event_timestamp(alert: dict[str, Any], *, now: datetime | None = None) -> str: + value = str(alert.get("timestamp") or "").strip() + if value and len(value) <= 64 and re.match(r"^\d{4}-\d{2}-\d{2}T", value): + return value + return (now or datetime.now(timezone.utc)).isoformat().replace("+00:00", "Z") + + +def build_alertmanager_payload( + alert: dict[str, Any], *, now: datetime | None = None +) -> dict[str, Any]: + """Build a bounded envelope without raw logs or endpoint identities.""" + + rule = alert.get("rule") or {} + if not isinstance(rule, dict): + raise ValueError("invalid_rule") + try: + rule_level = int(rule.get("level")) + except (TypeError, ValueError) as exc: + raise ValueError("invalid_rule_level") from exc + if not 0 <= rule_level <= 16: + raise ValueError("invalid_rule_level") + + rule_id = _safe_token(rule.get("id"), fallback="unknown", limit=24) + agent_alias = _agent_alias(alert) + groups = _rule_groups(rule) + techniques = _mitre_techniques(rule) + severity = "critical" if rule_level >= 12 else "warning" + recurrence_source = "|".join( + ("wazuh-event-v1", rule_id, agent_alias, ",".join(groups)) + ) + fingerprint = hashlib.sha256(recurrence_source.encode()).hexdigest()[:32] + alertname = f"WazuhSecurityEvent_{rule_id}" + starts_at = _event_timestamp(alert, now=now) + + labels = { + "alertname": alertname, + "severity": severity, + "namespace": "awoooi", + "component": "wazuh-manager", + "service": "wazuh-manager", + "tool": "wazuh", + "alert_category": "security", + "deployment": agent_alias, + "asset_alias": agent_alias, + "wazuh_rule_id": rule_id, + "wazuh_rule_level": str(rule_level), + "wazuh_rule_groups": ",".join(groups), + "mitre_techniques": ",".join(techniques), + "raw_payload_persisted": "false", + "active_response_requested": "false", + } + annotations = { + "summary": f"Wazuh rule {rule_id} level {rule_level} requires AI security triage", + "description": "Sanitized Wazuh security signal; full evidence remains in the source system.", + } + return { + "version": "4", + "groupKey": fingerprint, + "status": "firing", + "receiver": "awoooi-wazuh", + "groupLabels": { + "alertname": alertname, + "deployment": agent_alias, + }, + "commonLabels": labels, + "commonAnnotations": annotations, + "alerts": [ + { + "status": "firing", + "labels": labels, + "annotations": annotations, + "startsAt": starts_at, + "fingerprint": fingerprint, + } + ], + } + + +def _validated_hook_url(raw_url: str) -> str: + parsed = urlsplit(raw_url.strip()) + if ( + parsed.scheme != "https" + or parsed.hostname != _ALLOWED_HOST + or parsed.port not in {None, 443} + or parsed.path != _ALLOWED_PATH + or parsed.query + or parsed.fragment + or parsed.username + or parsed.password + ): + raise ValueError("hook_url_not_allowlisted") + return f"https://{_ALLOWED_HOST}{_ALLOWED_PATH}" + + +def _load_alert(path: str) -> dict[str, Any]: + alert_path = Path(path) + with alert_path.open("rb") as handle: + raw = handle.read(_MAX_ALERT_BYTES + 1) + if len(raw) > _MAX_ALERT_BYTES: + raise ValueError("alert_file_too_large") + payload = json.loads(raw.decode("utf-8")) + if not isinstance(payload, dict): + raise ValueError("alert_payload_not_object") + return payload + + +def _post_payload(hook_url: str, payload: dict[str, Any]) -> int: + body = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode() + request = Request( + hook_url, + data=body, + method="POST", + headers={ + "Content-Type": "application/json", + "User-Agent": "wazuh-custom-awoooi/1", + }, + ) + try: + with build_opener(_NoRedirect()).open(request, timeout=10) as response: + status = int(response.getcode()) + except HTTPError as exc: + status = int(exc.code) + if not 200 <= status < 300: + raise RuntimeError(f"webhook_http_status_{status}") + return status + + +def _self_test() -> None: + fixture = { + "timestamp": "2026-07-15T00:00:00Z", + "manager": {"name": "manager"}, + "agent": {"id": "000", "name": "synthetic"}, + "rule": { + "id": "100001", + "level": 10, + "groups": ["awoooi_canary"], + "mitre": {"id": ["T1110"]}, + }, + "full_log": "must-not-leave-source", + } + encoded = json.dumps(build_alertmanager_payload(fixture), sort_keys=True) + if "must-not-leave-source" in encoded or "synthetic" in encoded: + raise RuntimeError("raw_field_exposure") + _validated_hook_url(f"https://{_ALLOWED_HOST}{_ALLOWED_PATH}") + + +def main(argv: list[str]) -> int: + try: + if argv[1:] == ["--self-test"]: + _self_test() + return 0 + if len(argv) < 4: + raise ValueError("expected_alert_api_key_hook_url_arguments") + # argv[2] is the Wazuh Integrator api_key slot. This integration does + # not configure, read, log, or forward it. + alert = _load_alert(argv[1]) + hook_url = _validated_hook_url(argv[3]) + _post_payload(hook_url, build_alertmanager_payload(alert)) + return 0 + except Exception as exc: # Wazuh retries on a non-zero integration result. + print( + f"awoooi_wazuh_bridge_failed:{type(exc).__name__}", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/infra/ansible/playbooks/wazuh-alertmanager-integration.yml b/infra/ansible/playbooks/wazuh-alertmanager-integration.yml new file mode 100644 index 000000000..c5b31ee76 --- /dev/null +++ b/infra/ansible/playbooks/wazuh-alertmanager-integration.yml @@ -0,0 +1,281 @@ +--- +- name: Converge the Wazuh to AWOOOI sanitized alert ingress + hosts: host_112 + gather_facts: false + become: true + serial: 1 + any_errors_fatal: true + vars: + wazuh_integration_src: >- + {{ playbook_dir }}/../files/wazuh/custom-awoooi-alertmanager.py + wazuh_integration_dest: /var/ossec/integrations/custom-awoooi-alertmanager + wazuh_ossec_conf: /var/ossec/etc/ossec.conf + wazuh_hook_url: https://awoooi.wooo.work/api/v1/webhooks/alertmanager + wazuh_receipt_dir: /var/lib/awoooi-wazuh-alert-ingress + wazuh_receipt_path: /var/lib/awoooi-wazuh-alert-ingress/receipt.env + + tasks: + - name: Verify the repo-owned integration asset before host mutation + ansible.builtin.command: + argv: + - /usr/bin/python3 + - "{{ wazuh_integration_src }}" + - --self-test + delegate_to: localhost + become: false + check_mode: false + changed_when: false + + - name: Compute the repo-owned integration source fingerprint + ansible.builtin.command: + argv: + - /usr/bin/sha256sum + - "{{ wazuh_integration_src }}" + delegate_to: localhost + become: false + register: wazuh_source_sha256 + check_mode: false + changed_when: false + + - name: Probe the exact privileged convergence capability + ansible.builtin.command: + argv: + - /usr/bin/true + become: true + register: wazuh_privilege_preflight + check_mode: false + changed_when: false + failed_when: false + ignore_errors: true + no_log: true + + - name: Fail closed before mutation when privilege is unavailable + ansible.builtin.assert: + that: + - not (wazuh_privilege_preflight.failed | default(true)) + - (wazuh_privilege_preflight.rc | default(1)) == 0 + fail_msg: wazuh_privileged_convergence_capability_missing + become: false + + - name: Verify the Wazuh manager is active before convergence + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - wazuh-manager.service + register: wazuh_manager_before + check_mode: false + changed_when: false + failed_when: wazuh_manager_before.rc != 0 + no_log: true + + - name: Inspect existing integration script state without reading content + ansible.builtin.stat: + path: "{{ wazuh_integration_dest }}" + register: wazuh_integration_before + + - name: Inspect existing public-safe convergence receipt + ansible.builtin.stat: + path: "{{ wazuh_receipt_path }}" + register: wazuh_receipt_before + + - name: Converge integration with remote-only rollback artifacts + block: + - name: Install the public-safe Wazuh integration script + ansible.builtin.copy: + src: "{{ wazuh_integration_src }}" + dest: "{{ wazuh_integration_dest }}" + owner: root + group: wazuh + mode: "0750" + backup: true + register: wazuh_integration_copy + + - name: Install the bounded Wazuh Integrator configuration + ansible.builtin.blockinfile: + path: "{{ wazuh_ossec_conf }}" + insertbefore: "" + marker: "" + block: |2 + + custom-awoooi-alertmanager + {{ wazuh_hook_url }} + 10 + json + 10 + 3 + + backup: true + register: wazuh_config_update + no_log: true + + - name: Validate current Wazuh XML without returning configuration + ansible.builtin.command: + argv: + - /usr/bin/python3 + - -c + - >- + import xml.etree.ElementTree as ET; + ET.parse('{{ wazuh_ossec_conf }}') + check_mode: false + changed_when: false + no_log: true + + - name: Validate the installed integration script + ansible.builtin.command: + argv: + - "{{ wazuh_integration_dest }}" + - --self-test + when: not ansible_check_mode + changed_when: false + + - name: Restart Wazuh only when managed assets changed + ansible.builtin.systemd: + name: wazuh-manager.service + state: restarted + when: + - not ansible_check_mode + - wazuh_integration_copy.changed or wazuh_config_update.changed + + - name: Verify Wazuh manager after controlled convergence + ansible.builtin.command: + argv: + - /usr/bin/systemctl + - is-active + - wazuh-manager.service + register: wazuh_manager_after + retries: 6 + delay: 5 + until: wazuh_manager_after.rc == 0 + check_mode: false + changed_when: false + no_log: true + + - name: Verify Wazuh Integrator process after apply + ansible.builtin.command: + argv: + - /var/ossec/bin/wazuh-control + - status + register: wazuh_process_status + when: not ansible_check_mode + changed_when: false + failed_when: >- + 'wazuh-integratord is running' not in wazuh_process_status.stdout + no_log: true + + - name: Verify AWOOOI ingress reachability from the Wazuh manager + ansible.builtin.uri: + url: https://awoooi.wooo.work/api/v1/webhooks/health + method: GET + status_code: 200 + return_content: false + validate_certs: true + timeout: 10 + changed_when: false + + - name: Verify installed script fingerprint matches repo source + ansible.builtin.stat: + path: "{{ wazuh_integration_dest }}" + checksum_algorithm: sha256 + get_checksum: true + register: wazuh_installed_script + when: not ansible_check_mode + no_log: true + + - name: Assert installed script fingerprint parity + ansible.builtin.assert: + that: + - wazuh_installed_script.stat.checksum == (wazuh_source_sha256.stdout.split() | first) + when: not ansible_check_mode + no_log: true + + - name: Create the public-safe convergence receipt directory + ansible.builtin.file: + path: "{{ wazuh_receipt_dir }}" + state: directory + owner: root + group: root + mode: "0755" + + - name: Write the public-safe convergence receipt + ansible.builtin.copy: + dest: "{{ wazuh_receipt_path }}" + owner: root + group: root + mode: "0644" + backup: true + content: | + schema_version=awoooi_wazuh_alert_ingress_receipt_v1 + integration_name=custom-awoooi-alertmanager + script_sha256={{ wazuh_source_sha256.stdout.split() | first }} + config_present=1 + manager_active=1 + integrator_running=1 + ingress_reachable=1 + raw_payload_persisted=0 + secret_value_exposed=0 + active_response_enabled=0 + register: wazuh_receipt_update + + rescue: + - name: Restore the prior Wazuh configuration from remote backup + ansible.builtin.copy: + remote_src: true + src: "{{ wazuh_config_update.backup_file }}" + dest: "{{ wazuh_ossec_conf }}" + owner: root + group: wazuh + mode: "0660" + when: + - wazuh_config_update is defined + - wazuh_config_update.backup_file | default('') | length > 0 + no_log: true + + - name: Restore the prior integration script from remote backup + ansible.builtin.copy: + remote_src: true + src: "{{ wazuh_integration_copy.backup_file }}" + dest: "{{ wazuh_integration_dest }}" + owner: root + group: wazuh + mode: "0750" + when: + - wazuh_integration_before.stat.exists + - wazuh_integration_copy is defined + - wazuh_integration_copy.backup_file | default('') | length > 0 + no_log: true + + - name: Remove a newly-created integration script during rollback + ansible.builtin.file: + path: "{{ wazuh_integration_dest }}" + state: absent + when: not wazuh_integration_before.stat.exists + + - name: Restore the prior public-safe receipt from remote backup + ansible.builtin.copy: + remote_src: true + src: "{{ wazuh_receipt_update.backup_file }}" + dest: "{{ wazuh_receipt_path }}" + owner: root + group: root + mode: "0644" + when: + - wazuh_receipt_before.stat.exists + - wazuh_receipt_update is defined + - wazuh_receipt_update.backup_file | default('') | length > 0 + no_log: true + + - name: Remove a newly-created public-safe receipt during rollback + ansible.builtin.file: + path: "{{ wazuh_receipt_path }}" + state: absent + when: not wazuh_receipt_before.stat.exists + + - name: Restart Wazuh after rollback + ansible.builtin.systemd: + name: wazuh-manager.service + state: restarted + + - name: Stop with a bounded rollback terminal + ansible.builtin.fail: + msg: wazuh_alertmanager_integration_rolled_back diff --git a/k8s/awoooi-prod/06-deployment-api.yaml b/k8s/awoooi-prod/06-deployment-api.yaml index a4aa18e96..035e28d12 100644 --- a/k8s/awoooi-prod/06-deployment-api.yaml +++ b/k8s/awoooi-prod/06-deployment-api.yaml @@ -157,12 +157,12 @@ spec: - name: AWOOOI_BUILD_COMMIT_SHA # 2026-06-29 Codex: CD rewrites this to the deployed image tag so # production deploy readback does not rely on a stale static snapshot. - value: "c56dee39edf839cadaaec84715324616a4afde15" + value: "1c21c7acc892889a1710ca4e426ed551fabe0c77" - name: AWOOOI_DESIRED_API_IMAGE_TAG # 2026-06-30 Codex: CD rewrites this alongside AWOOOI_BUILD_COMMIT_SHA. # Production readback compares runtime image truth against this # GitOps desired tag instead of doing a slow Gitea raw fetch. - value: "c56dee39edf839cadaaec84715324616a4afde15" + value: "1c21c7acc892889a1710ca4e426ed551fabe0c77" - name: DATABASE_POOL_SIZE # 2026-07-01 Codex: production role `awoooi` currently has a low # connection limit. Keep API pool conservative until DB role diff --git a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml index 6eb7bfd10..77d964312 100644 --- a/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml +++ b/k8s/awoooi-prod/08-deployment-ansible-executor-broker.yaml @@ -66,7 +66,7 @@ spec: - name: DATABASE_NULL_POOL value: "true" - name: AWOOOI_BUILD_COMMIT_SHA - value: "c56dee39edf839cadaaec84715324616a4afde15" + value: "1c21c7acc892889a1710ca4e426ed551fabe0c77" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER value: "false" - name: ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER @@ -89,6 +89,8 @@ spec: value: "/run/secrets/ssh_mcp_key" - name: AWOOOP_ANSIBLE_CHECK_MODE_KNOWN_HOSTS_PATH value: "/etc/ssh-mcp/known_hosts" + - name: AWOOOP_ANSIBLE_BECOME_PASSWORD_FILE + value: "/run/secrets/host112-become/password" volumeMounts: - name: ssh-mcp-key mountPath: /run/secrets/ssh_mcp_key @@ -98,6 +100,9 @@ spec: mountPath: /etc/ssh-mcp/known_hosts subPath: known_hosts readOnly: true + - name: host112-become-password + mountPath: /run/secrets/host112-become + readOnly: true resources: requests: cpu: "100m" @@ -136,3 +141,11 @@ spec: secret: secretName: ssh-mcp-key defaultMode: 0400 + - name: host112-become-password + secret: + secretName: awoooi-secrets + optional: true + defaultMode: 0400 + items: + - key: HOST112_ANSIBLE_BECOME_PASSWORD + path: password diff --git a/k8s/awoooi-prod/08-deployment-worker.yaml b/k8s/awoooi-prod/08-deployment-worker.yaml index 24ebe6249..74365bbd1 100644 --- a/k8s/awoooi-prod/08-deployment-worker.yaml +++ b/k8s/awoooi-prod/08-deployment-worker.yaml @@ -178,7 +178,7 @@ spec: - name: DATABASE_BOOTSTRAP_DDL_ENABLED value: "false" - name: AWOOOI_BUILD_COMMIT_SHA - value: "c56dee39edf839cadaaec84715324616a4afde15" + value: "1c21c7acc892889a1710ca4e426ed551fabe0c77" - name: ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER value: "true" - name: ENABLE_SECURITY_CONTROL_PLANE_MAINTENANCE_WORKER @@ -217,6 +217,11 @@ spec: value: "180" - name: AWOOOP_ANSIBLE_CHECK_MODE_STARTUP_SLEEP_SECONDS value: "30" + volumeMounts: + - name: service-registry + mountPath: /app/ops/config/service-registry.yaml + subPath: service-registry.yaml + readOnly: true resources: requests: cpu: "100m" @@ -269,3 +274,7 @@ spec: matchLabels: app: awoooi-worker topologyKey: kubernetes.io/hostname + volumes: + - name: service-registry + configMap: + name: service-registry diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 0b8ca053a..c80c6ebd5 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -40,9 +40,9 @@ resources: # ⚠️ 重要: name 必須與 deployment YAML 中的 image 完全匹配 (含 tag) # newName + newTag 由 CI 透過 kustomize edit set image 注入 images: -- digest: sha256:edf1d6c90ebf4fdbb2b200cade755d92d1cd274663fc79230a7c74b46848a6cf +- digest: sha256:ee55ad5274792aae2fc26570fbbcc46e07eb366104089c3cfe68ab5c5ca0fa3d name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api -- digest: sha256:6189ce1c21b2bb92c372ec8f83d6858607856194c3554e19dea27286abe15005 +- digest: sha256:25e1fa50c7e5554a2ae66c6c6530b0ebd687155619bd8ab6ee7ea2f67a10e3cc name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web diff --git a/ops/signoz/organization-canonical-route-migration.yaml b/ops/signoz/organization-canonical-route-migration.yaml index 4810622a4..d0b2a6426 100644 --- a/ops/signoz/organization-canonical-route-migration.yaml +++ b/ops/signoz/organization-canonical-route-migration.yaml @@ -202,7 +202,17 @@ runtime_observations: clickhouse_native_failed_artifact_retention_status: pending_bounded_retention_decision clickhouse_native_resume_inventory_status: pending_durable_submitted_inventory_contract signoz_sqlite_application_consistent_backup_status: pending - service_registry_runtime_mirror_status: partial_degraded + signoz_metadata_export_source_contract_status: implemented_tested_not_deployed + signoz_metadata_export_policy: config/signoz/metadata-export-policy.json + signoz_metadata_exporter: scripts/backup/signoz-metadata-export.py + signoz_metadata_export_verifier: scripts/backup/verify-signoz-metadata-export.py + signoz_metadata_restore_driver: scripts/backup/signoz-metadata-restore-drill.py + signoz_metadata_controlled_deployer: scripts/ops/deploy-signoz-metadata-toolchain.sh + signoz_metadata_runtime_export_terminal: pending_authenticated_stable_export + signoz_metadata_runtime_restore_terminal: pending_isolated_same_version_target + signoz_metadata_zero_residue_terminal: pending + signoz_metadata_full_sqlite_completion_claim: false + service_registry_runtime_mirror_status: source_manifest_exact_production_readback_pending service_registry_runtime_mirror_work_item_id: P0-OBS-002-ASSET-DRIFT-001 github_freeze_legacy_asset_status: pending_controlled_retirement github_freeze_legacy_asset_work_item_id: P0-OBS-002-ASSET-DRIFT-002 diff --git a/ops/signoz/p0-obs-002-post-closure-regression.yaml b/ops/signoz/p0-obs-002-post-closure-regression.yaml index 48f1f5f8c..0800683ed 100644 --- a/ops/signoz/p0-obs-002-post-closure-regression.yaml +++ b/ops/signoz/p0-obs-002-post-closure-regression.yaml @@ -57,22 +57,21 @@ asset_reconciliation: drift_work_item_id: P0-OBS-002-ASSET-DRIFT-001 source: ops/config/service-registry.yaml runtime_mirror: k8s/awoooi-prod/15-service-registry-configmap.yaml - source_service_count: 27 - runtime_mirror_service_count: 24 - exact_shared_service_count: 24 + source_service_count: 28 + runtime_mirror_service_count: 28 + exact_shared_service_count: 28 shared_service_drift_count: 0 - source_only_services: - - bitan-app - - sentry - - signoz + source_only_services: [] + source_runtime_manifest_exact: true + production_runtime_readback_status: pending_current_sha_readback signoz_clickhouse_exact_match: true live_signoz_query_host: 192.168.0.110 source_signoz_host: 192.168.0.188 terminal: partial_degraded safe_next_action: >- - Reconcile the three source-only services and the stale SignOz query host - against production runtime before atomically regenerating the ConfigMap; - do not copy the stale 188 SignOz row into the runtime mirror. + Verify the exact 28-service source/runtime manifest against production, + then reconcile the SignOz query-host identity against live 110 readback; + source-manifest parity alone does not close the production drift item. github_freeze_legacy_asset_drift: drift_work_item_id: P0-OBS-002-ASSET-DRIFT-002 superseded_by: global_product_governance_v2 @@ -761,9 +760,57 @@ pending_verifiers: status: pending_supported_non_raw_export_or_coordinated_snapshot raw_sqlite_read_or_sync_allowed: false sqlite_cli_present_in_runtime: false + source_contract_status: implemented_tested_not_deployed + source_contract: + policy: config/signoz/metadata-export-policy.json + exporter: scripts/backup/signoz-metadata-export.py + shared_contract: scripts/backup/signoz_metadata_contract.py + independent_export_verifier: scripts/backup/verify-signoz-metadata-export.py + isolated_restore_driver: scripts/backup/signoz-metadata-restore-drill.py + contract_tests: scripts/backup/tests/test_signoz_metadata_export_contract.py + controlled_deployer: scripts/ops/deploy-signoz-metadata-toolchain.sh + deployer_tests: scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py + expected_file_count: 8 + hashes: + policy_sha256: df09dbf91d30bcf4d1a2119b1c301240a252fddd446c56ecba253d5399526533 + exporter_sha256: b184d55bd5601377d25e78e422de172d01c3f8b8ea2de948b65c915201094956 + shared_contract_sha256: f719d3f2ec86acfafe12be1883e57e6675b8b64caab0ee16d6054c8b383fa213 + independent_export_verifier_sha256: cbab56dd3919866a96550c1dce46ec67783cd6f00473e4491861e17d1430048c + isolated_restore_driver_sha256: 38ce70b3f891a3ce8edfaeb880ccf69d0bb6817c3f801a0aefa0f06341901997 + contract_tests_sha256: 474d9dfcfe170c62425356ea9f06ed0dc1629b39215ee80f395f721c7d43a923 + controlled_deployer_sha256: b3edcb3629aa3069fcecde5812cc4f21e83e324eec89b3c75f61cc73444adb97 + deployer_tests_sha256: cae135b2075d845f13d762c751d9e6de88bf806b37dcd5bd8837487211a20880 + test_terminal: 19_passed + portable_assets: + - dashboards + - alert_rules + - explorer_views + export_only_assets: + - roles + - organization_preferences + - user_preferences + - global_config + forbidden_assets: + - personal_access_tokens + - authentication_domains + - notification_channels + - users_and_credentials + - invitations + - sessions + raw_sqlite_read: false + raw_volume_read: false + secret_value_persistence: false + full_sqlite_completion_claim: false + runtime_export_terminal: pending_authenticated_stable_export + runtime_restore_terminal: pending_isolated_same_version_target + runtime_zero_residue_terminal: pending required_preconditions: - - supported_metadata_export_or_application_consistent_snapshot_design - - independent_restore_verifier_without_raw_session_or_secret_read + - deploy_exact_source_contract_with_hash_and_mode_readback + - trusted_service_account_secret_reference_without_value_readback + - authenticated_two_read_stable_production_export + - isolated_same_version_restore_semantic_readback + - zero_container_volume_network_listener_residue + - reissue_secret_bearing_assets_from_secret_references signoz_backup_offsite_dr: status: pending_offsite_snapshot_and_restore_verifier current_repository_scope: local_same_failure_domain @@ -816,7 +863,7 @@ terminal: - signoz_backup_offsite_dr_pending - clickhouse_native_failed_artifact_retention_pending - clickhouse_native_resume_submitted_inventory_pending - - service_registry_runtime_mirror_reconciliation_pending + - service_registry_production_and_live_host_readback_pending - github_freeze_legacy_asset_retirement_pending - monitoring_generator_duplicate_awoooi_api_identity_pending safe_next_action: >- @@ -826,8 +873,9 @@ terminal: target and restore it independently, then apply an explicit failed-artifact retention decision. Persist the submitted source inventory before BACKUP so same-run resume cannot bind an old artifact to newer live counters. Keep raw - SQLite reads and syncs disabled. Reconcile the source-only service registry - rows against live runtime before regenerating its K8s ConfigMap mirror, and - retire legacy GitHub runner assets only after the Gitea replacement verifier - is ready. Deduplicate the monitoring registry's awoooi-api identity before - applying regenerated scrape configuration. + SQLite reads and syncs disabled. Verify the exact source/runtime service + registry manifest against production and reconcile its SignOz query-host + identity against live runtime. Retire legacy GitHub runner assets only after + the Gitea replacement verifier is ready. Deduplicate the monitoring + registry's awoooi-api identity before applying regenerated scrape + configuration. diff --git a/scripts/backup/signoz-metadata-export.py b/scripts/backup/signoz-metadata-export.py new file mode 100755 index 000000000..50f9e123e --- /dev/null +++ b/scripts/backup/signoz-metadata-export.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +"""Create a stable, secret-scanned SigNoz metadata API export bundle.""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path +from typing import Any + +from signoz_metadata_contract import ( + DEFAULT_POLICY, + ApiClient, + ContractError, + canonical_json_bytes, + load_policy, + receipt, + require_no_symlink_components, + sha256_bytes, + sha256_file, + utc_now, + validate_asset_document, + validate_base_url, + validate_identity, + validate_new_private_destination, + validate_token_reference, + write_new_private_atomic, + write_private, +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description=( + "Export an allowlisted SigNoz metadata subset without raw SQLite or " + "Docker volume access." + ) + ) + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--apply", action="store_true") + parser.add_argument("--base-url", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--credential-file") + parser.add_argument("--policy", default=str(DEFAULT_POLICY)) + parser.add_argument("--trace-id", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--work-item-id", default="P0-OBS-002") + parser.add_argument("--receipt-file") + return parser.parse_args() + + +def validate_destination(path: Path) -> None: + if not path.is_absolute(): + raise ContractError("output_dir_must_be_absolute") + if path.exists() or path.is_symlink(): + raise ContractError("output_dir_must_not_exist") + parent = path.parent + require_no_symlink_components(parent, label="output_parent") + try: + metadata = parent.lstat() + except OSError as exc: + raise ContractError("output_parent_unavailable") from exc + if not parent.is_dir() or parent.is_symlink(): + raise ContractError("output_parent_must_be_real_directory") + if metadata.st_uid != os.geteuid() or not os.access(parent, os.W_OK | os.X_OK): + raise ContractError("output_parent_not_owned_and_writable") + + +def emit_stdout(value: dict[str, Any]) -> None: + print(json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))) + + +def run_check(args: argparse.Namespace, policy: dict[str, Any]) -> dict[str, Any]: + if args.credential_file: + validate_token_reference(Path(args.credential_file), read_value=False) + return receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="check_pass_no_write", + detail=( + f"policy_assets_{len(policy['assets'])}_raw_sqlite_0_" + "raw_volume_0_output_write_0" + ), + ) + + +def stable_read( + client: ApiClient, + policy: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, int]]: + pass_documents: list[dict[str, Any]] = [] + pass_counts: list[dict[str, int]] = [] + for _ in range(policy["limits"]["stable_read_count"]): + documents: dict[str, Any] = {} + counts: dict[str, int] = {} + for asset in policy["assets"]: + document = client.request_json(asset["endpoint"]) + counts[asset["id"]] = validate_asset_document(document, asset, policy) + documents[asset["id"]] = document + pass_documents.append(documents) + pass_counts.append(counts) + + if canonical_json_bytes(pass_documents[0]) != canonical_json_bytes( + pass_documents[1] + ): + raise ContractError("source_metadata_drift_between_stable_reads") + if pass_counts[0] != pass_counts[1]: + raise ContractError("source_item_count_drift_between_stable_reads") + return pass_documents[0], pass_counts[0] + + +def create_bundle( + args: argparse.Namespace, + policy_path: Path, + policy: dict[str, Any], + documents: dict[str, Any], + counts: dict[str, int], +) -> dict[str, Any]: + output_dir = Path(args.output_dir) + temp_dir: Path | None = Path( + tempfile.mkdtemp(prefix=f".{output_dir.name}.partial.", dir=output_dir.parent) + ) + temp_dir.chmod(0o700) + assets_dir = temp_dir / "assets" + assets_dir.mkdir(mode=0o700) + try: + asset_receipts: list[dict[str, Any]] = [] + for asset in policy["assets"]: + asset_id = asset["id"] + payload = canonical_json_bytes(documents[asset_id]) + asset_path = assets_dir / f"{asset_id}.json" + write_private(asset_path, payload) + asset_receipts.append( + { + "id": asset_id, + "classification": asset["classification"], + "endpoint": asset["endpoint"], + "file": f"assets/{asset_id}.json", + "sha256": sha256_bytes(payload), + "size_bytes": len(payload), + "item_count": counts[asset_id], + } + ) + + portable_count = sum( + item["classification"] == "portable" for item in asset_receipts + ) + manifest = { + "schema": "awoooi_signoz_metadata_export_bundle_v1", + "trace_id": args.trace_id, + "run_id": args.run_id, + "work_item_id": args.work_item_id, + "created_at": utc_now(), + "source_runtime_id": policy["source_runtime"]["canonical_id"], + "source_version": policy["source_runtime"]["signoz_version"], + "source_base_url_sha256": sha256_bytes( + validate_base_url(args.base_url).encode("utf-8") + ), + "policy_sha256": sha256_file(policy_path), + "stable_read_count": policy["limits"]["stable_read_count"], + "stable_read_terminal": "pass", + "raw_sqlite_read": False, + "raw_volume_read": False, + "sensitive_value_persisted": False, + "assets": asset_receipts, + "coverage": { + "portable_asset_count": portable_count, + "export_only_asset_count": len(asset_receipts) - portable_count, + "forbidden_endpoint_count": len(policy["forbidden_endpoints"]), + "full_sqlite_coverage": False, + }, + "terminal": "portable_metadata_export_created_restore_unverified", + "completion_claim": False, + } + write_private(temp_dir / "manifest.json", canonical_json_bytes(manifest)) + + receipt_rows = [ + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="sensor_source", + terminal="pass", + detail="allowlisted_api_get_two_stable_reads", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="normalized_asset_identity", + terminal="pass", + detail=f"canonical_asset_count_{len(asset_receipts)}", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="source_of_truth_diff", + terminal="pass", + detail="second_read_matches_first_read_canonical_hash", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="ai_decision", + terminal="pass", + detail="candidate_action_export_portable_metadata_subset", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="risk_policy_decision", + terminal="pass", + detail="low_risk_read_only_source_no_raw_sqlite_no_secret_persistence", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="check_mode", + terminal="pass", + detail="policy_destination_and_credential_reference_preconditions_pass", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="bounded_execution", + terminal="pass", + detail=f"private_atomic_bundle_assets_{len(asset_receipts)}", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="rollback", + terminal="not_required", + detail="production_source_read_only_output_created_atomically", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="partial_degraded", + detail="portable_export_created_independent_restore_verifier_pending", + ), + ] + receipt_payload = b"".join(canonical_json_bytes(item) for item in receipt_rows) + write_private(temp_dir / "receipts.jsonl", receipt_payload) + os.replace(temp_dir, output_dir) + try: + parent_descriptor = os.open(output_dir.parent, os.O_RDONLY) + try: + os.fsync(parent_descriptor) + finally: + os.close(parent_descriptor) + except OSError: + pass + temp_dir = None + return manifest + finally: + if temp_dir is not None and temp_dir.exists(): + shutil.rmtree(temp_dir) + + +def run_apply( + args: argparse.Namespace, policy_path: Path, policy: dict[str, Any] +) -> dict[str, Any]: + if not args.receipt_file: + raise ContractError("receipt_file_required_for_apply") + if not args.credential_file: + raise ContractError("credential_file_required_for_apply") + token = validate_token_reference(Path(args.credential_file), read_value=True) + if token is None: + raise ContractError("credential_value_unavailable") + client = ApiClient( + base_url=args.base_url, + header_name=policy["authentication"]["header_name"], + token=token, + timeout_seconds=policy["limits"]["request_timeout_seconds"], + max_response_bytes=policy["limits"]["max_response_bytes"], + ) + documents, counts = stable_read(client, policy) + manifest = create_bundle(args, policy_path, policy, documents, counts) + return receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="partial_degraded", + detail=( + "bundle_created_manifest_hash_" + f"{sha256_bytes(canonical_json_bytes(manifest))}_restore_pending" + ), + ) + + +def persist_optional_receipt(args: argparse.Namespace, value: dict[str, Any]) -> None: + if args.receipt_file: + write_new_private_atomic( + Path(args.receipt_file), + canonical_json_bytes(value), + label="export_terminal_receipt", + ) + + +def main() -> int: + args = parse_args() + try: + validate_identity(args.trace_id, label="trace_id") + validate_identity(args.run_id, label="run_id") + validate_identity(args.work_item_id, label="work_item_id") + validate_base_url(args.base_url) + validate_destination(Path(args.output_dir)) + if args.apply and not args.receipt_file: + raise ContractError("receipt_file_required_for_apply") + if args.receipt_file: + validate_new_private_destination( + Path(args.receipt_file), label="export_terminal_receipt" + ) + policy_path = Path(args.policy) + policy = load_policy(policy_path) + if policy["work_item_id"] != args.work_item_id: + raise ContractError("work_item_policy_mismatch") + if args.check: + result = run_check(args, policy) + else: + result = run_apply(args, policy_path, policy) + persist_optional_receipt(args, result) + emit_stdout(result) + except (ContractError, OSError) as exc: + try: + validate_identity(args.trace_id, label="trace_id") + validate_identity(args.run_id, label="run_id") + validate_identity(args.work_item_id, label="work_item_id") + failure = receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal=( + "check_failed_no_write" + if args.check + else "failed_export_not_closable" + ), + detail=( + f"metadata_export_failed_{exc}_output_" + f"{'created_unverified' if Path(args.output_dir).is_dir() else 'absent'}" + ), + ) + persist_optional_receipt(args, failure) + emit_stdout(failure) + except (ContractError, OSError) as receipt_exc: + print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr) + print(f"ERROR={exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backup/signoz-metadata-restore-drill.py b/scripts/backup/signoz-metadata-restore-drill.py new file mode 100755 index 000000000..364e959c9 --- /dev/null +++ b/scripts/backup/signoz-metadata-restore-drill.py @@ -0,0 +1,510 @@ +#!/usr/bin/env python3 +"""Restore a verified SigNoz metadata subset into an isolated API target.""" + +from __future__ import annotations + +import argparse +import json +import os +import stat + +# Child commands use fixed argv without a shell. +import subprocess # nosec B404 +import sys +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from signoz_metadata_contract import ( + DEFAULT_POLICY, + SHA256, + ApiClient, + ContractError, + canonical_json_bytes, + load_policy, + read_json_object, + receipt, + semantic_items, + sha256_bytes, + validate_base_url, + validate_asset_document, + validate_identity, + validate_new_private_destination, + validate_token_reference, + write_new_private_atomic, +) + + +VERIFIER = Path(__file__).with_name("verify-signoz-metadata-export.py") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + mode = parser.add_mutually_exclusive_group(required=True) + mode.add_argument("--check", action="store_true") + mode.add_argument("--apply", action="store_true") + mode.add_argument("--verify-cleanup", action="store_true") + parser.add_argument("--bundle-dir", required=True) + parser.add_argument("--policy", default=str(DEFAULT_POLICY)) + parser.add_argument("--target-base-url", required=True) + parser.add_argument("--credential-file") + parser.add_argument("--isolation-receipt", required=True) + parser.add_argument("--trace-id", required=True) + parser.add_argument("--run-id", required=True) + parser.add_argument("--work-item-id", default="P0-OBS-002") + parser.add_argument("--receipt-file") + return parser.parse_args() + + +def run_independent_bundle_verifier(args: argparse.Namespace) -> None: + command = [ + sys.executable, + str(VERIFIER), + "--bundle-dir", + args.bundle_dir, + "--policy", + args.policy, + "--expected-trace-id", + args.trace_id, + "--expected-run-id", + args.run_id, + "--expected-work-item-id", + args.work_item_id, + ] + # The verifier executable and argument vector are fixed. + result = subprocess.run( # nosec B603 + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=60, + check=False, + ) + if result.returncode != 0: + raise ContractError("independent_bundle_verifier_failed") + try: + verifier_receipt = json.loads(result.stdout) + except json.JSONDecodeError as exc: + raise ContractError("independent_bundle_verifier_receipt_invalid") from exc + if verifier_receipt.get("terminal") != "pass_export_verified_restore_pending": + raise ContractError("independent_bundle_verifier_terminal_invalid") + + +def validate_isolation_receipt( + args: argparse.Namespace, + policy: dict[str, Any], +) -> dict[str, Any]: + isolation_path = Path(args.isolation_receipt) + try: + isolation_metadata = isolation_path.lstat() + except OSError as exc: + raise ContractError("isolation_receipt_unavailable") from exc + if not stat.S_ISREG(isolation_metadata.st_mode): + raise ContractError("isolation_receipt_not_regular_file") + if ( + isolation_metadata.st_uid != os.geteuid() + or stat.S_IMODE(isolation_metadata.st_mode) != 0o600 + ): + raise ContractError("isolation_receipt_owner_or_mode_invalid") + isolation = read_json_object(isolation_path, label="isolation_receipt") + if isolation.get("schema") != "awoooi_signoz_metadata_restore_isolation_v1": + raise ContractError("isolation_receipt_schema_invalid") + for key, expected in ( + ("trace_id", args.trace_id), + ("run_id", args.run_id), + ("work_item_id", args.work_item_id), + ): + if isolation.get(key) != expected: + raise ContractError(f"isolation_receipt_{key}_mismatch") + + target = isolation.get("target") + if not isinstance(target, dict): + raise ContractError("isolation_target_missing") + isolation_policy = policy["restore_isolation"] + target_url = validate_base_url(args.target_base_url, loopback_only=True) + if target.get("base_url_sha256") != sha256_bytes(target_url.encode("utf-8")): + raise ContractError("isolation_target_url_hash_mismatch") + canonical_id = target.get("canonical_id") + if not isinstance(canonical_id, str) or not canonical_id.startswith( + "ephemeral-signoz-metadata-restore-" + ): + raise ContractError("isolation_target_canonical_id_invalid") + if target.get("image_ref") != isolation_policy["required_image_ref"]: + raise ContractError("isolation_target_image_ref_mismatch") + image_id = target.get("image_id") + if ( + not isinstance(image_id, str) + or not image_id.startswith("sha256:") + or not SHA256.fullmatch(image_id.removeprefix("sha256:")) + ): + raise ContractError("isolation_target_image_id_invalid") + required_values = { + "image_present_locally": True, + "image_pull_performed": False, + "production_network_attached": False, + "production_volume_attached": False, + "ephemeral_volumes_only": True, + "cleanup_controller_armed": True, + "zero_residue_verifier_armed": True, + "network_scope": "loopback_and_internal_only", + "resource_label_key": isolation_policy["resource_label_key"], + "resource_label_value": args.run_id, + } + for key, expected in required_values.items(): + if target.get(key) != expected: + raise ContractError(f"isolation_target_{key}_invalid") + return isolation + + +def load_portable_assets( + bundle_dir: Path, + policy: dict[str, Any], +) -> list[tuple[dict[str, Any], list[dict[str, Any]]]]: + result: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [] + for asset in policy["assets"]: + if asset["classification"] != "portable": + continue + path = bundle_dir / "assets" / f"{asset['id']}.json" + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContractError(f"portable_asset_read_failed_{asset['id']}") from exc + result.append((asset, semantic_items(document, asset))) + return result + + +def count_actions( + assets: list[tuple[dict[str, Any], list[dict[str, Any]]]], + policy: dict[str, Any], +) -> int: + action_count = sum(len(items) for _, items in assets) + if action_count <= 0: + raise ContractError("restore_source_portable_item_count_zero") + if action_count > policy["limits"]["max_restore_actions"]: + raise ContractError("restore_action_limit_exceeded") + return action_count + + +def make_client( + args: argparse.Namespace, + policy: dict[str, Any], + *, + read_token: bool, +) -> ApiClient | None: + if not args.credential_file: + if read_token: + raise ContractError("credential_file_required_for_restore_apply") + return None + token = validate_token_reference(Path(args.credential_file), read_value=read_token) + if not read_token: + return None + if token is None: + raise ContractError("credential_value_unavailable") + return ApiClient( + base_url=validate_base_url(args.target_base_url, loopback_only=True), + header_name=policy["authentication"]["header_name"], + token=token, + timeout_seconds=policy["limits"]["request_timeout_seconds"], + max_response_bytes=policy["limits"]["max_response_bytes"], + ) + + +def run_check( + args: argparse.Namespace, + policy: dict[str, Any], + assets: list[tuple[dict[str, Any], list[dict[str, Any]]]], +) -> dict[str, Any]: + make_client(args, policy, read_token=False) + action_count = count_actions(assets, policy) + return receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="check_pass_no_write", + detail=( + f"isolated_target_contract_pass_restore_actions_{action_count}_" + "production_write_0" + ), + ) + + +def read_target_stable( + client: ApiClient, + assets: list[tuple[dict[str, Any], list[dict[str, Any]]]], + policy: dict[str, Any], +) -> dict[str, list[dict[str, Any]]]: + snapshots: list[dict[str, list[dict[str, Any]]]] = [] + for _ in range(2): + current: dict[str, list[dict[str, Any]]] = {} + for asset, _ in assets: + document = client.request_json(asset["endpoint"]) + validate_asset_document(document, asset, policy) + current[asset["id"]] = semantic_items(document, asset) + snapshots.append(current) + if canonical_json_bytes(snapshots[0]) != canonical_json_bytes(snapshots[1]): + raise ContractError("restore_target_drift_between_stable_reads") + return snapshots[0] + + +def run_apply( + args: argparse.Namespace, + policy: dict[str, Any], + assets: list[tuple[dict[str, Any], list[dict[str, Any]]]], +) -> dict[str, Any]: + action_count = count_actions(assets, policy) + client = make_client(args, policy, read_token=True) + if client is None: + raise ContractError("restore_client_unavailable") + + before = read_target_stable(client, assets, policy) + if any(before[asset["id"]] for asset, _ in assets): + raise ContractError("restore_target_not_empty_fail_closed") + + writes = 0 + for asset, items in assets: + restore = asset["restore"] + for item in items: + client.request_write(restore["method"], restore["endpoint"], item) + writes += 1 + if writes != action_count: + raise ContractError("restore_write_count_mismatch") + + after = read_target_stable(client, assets, policy) + expected = {asset["id"]: items for asset, items in assets} + if canonical_json_bytes(after) != canonical_json_bytes(expected): + raise ContractError("restore_semantic_readback_mismatch") + terminal = receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="partial_degraded_cleanup_pending", + detail=( + f"isolated_restore_actions_{writes}_semantic_readback_pass_" + "cleanup_and_zero_residue_verifier_pending" + ), + ) + terminal["phase_receipts"] = [ + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="sensor_source", + terminal="pass", + detail="independently_verified_portable_export_bundle", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="normalized_asset_identity", + terminal="pass", + detail=f"portable_assets_{len(assets)}_restore_actions_{writes}", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="source_of_truth_diff", + terminal="pass", + detail="isolated_target_two_read_stable_and_empty", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="ai_decision", + terminal="pass", + detail="candidate_action_restore_portable_subset_to_ephemeral_target", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="risk_policy_decision", + terminal="pass", + detail="high_risk_bounded_loopback_ephemeral_no_production_attachment", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="check_mode", + terminal="pass", + detail="isolation_bundle_action_limit_and_empty_target_pass", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="bounded_execution", + terminal="pass", + detail=f"restore_actions_{writes}_delete_actions_0", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="independent_post_verifier", + terminal="pass", + detail="two_read_semantic_parity_pass", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="rollback", + terminal="cleanup_pending", + detail="ephemeral_target_cleanup_controller_armed", + ), + receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="learning_writeback", + terminal="pending", + detail="requires_zero_residue_terminal_before_learning_ack", + ), + ] + return terminal + + +def docker_label_count(resource: str, label: str) -> int: + command_by_resource = { + "container": ["docker", "ps", "-a", "--quiet", "--filter", f"label={label}"], + "volume": ["docker", "volume", "ls", "--quiet", "--filter", f"label={label}"], + "network": ["docker", "network", "ls", "--quiet", "--filter", f"label={label}"], + } + try: + # The Docker inventory command is selected from the fixed map above. + result = subprocess.run( # nosec B603 + command_by_resource[resource], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=15, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise ContractError(f"cleanup_{resource}_inventory_failed") from exc + if result.returncode != 0: + raise ContractError(f"cleanup_{resource}_inventory_failed") + return len([line for line in result.stdout.splitlines() if line.strip()]) + + +def require_target_unreachable(base_url: str) -> None: + base_url = validate_base_url(base_url, loopback_only=True) + request = urllib.request.Request(base_url, method="GET") + try: + # base_url is validated as loopback HTTP(S) immediately above. + with urllib.request.urlopen( # nosec B310 + request, timeout=2 + ): + pass + except urllib.error.HTTPError as exc: + raise ContractError("cleanup_target_listener_still_present") from exc + except (urllib.error.URLError, TimeoutError, OSError): + return + raise ContractError("cleanup_target_listener_still_present") + + +def run_cleanup_verifier( + args: argparse.Namespace, + policy: dict[str, Any], +) -> dict[str, Any]: + label = f"{policy['restore_isolation']['resource_label_key']}={args.run_id}" + counts = { + resource: docker_label_count(resource, label) + for resource in ("container", "volume", "network") + } + if any(counts.values()): + raise ContractError("cleanup_resource_residue_present") + require_target_unreachable(args.target_base_url) + return receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal="pass_zero_residue_verified", + detail="container_0_volume_0_network_0_listener_0", + ) + + +def write_receipt(path: Path, value: dict[str, Any]) -> None: + write_new_private_atomic( + path, + canonical_json_bytes(value), + label="restore_terminal_receipt", + ) + + +def emit_result(value: dict[str, Any]) -> None: + print( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + + +def main() -> int: + args = parse_args() + try: + validate_identity(args.trace_id, label="trace_id") + validate_identity(args.run_id, label="run_id") + validate_identity(args.work_item_id, label="work_item_id") + validate_base_url(args.target_base_url, loopback_only=True) + if args.apply and not args.receipt_file: + raise ContractError("receipt_file_required_for_restore_apply") + if args.receipt_file: + validate_new_private_destination( + Path(args.receipt_file), label="restore_terminal_receipt" + ) + policy = load_policy(Path(args.policy)) + run_independent_bundle_verifier(args) + validate_isolation_receipt(args, policy) + assets = load_portable_assets(Path(args.bundle_dir), policy) + if args.check: + result = run_check(args, policy, assets) + elif args.apply: + result = run_apply(args, policy, assets) + else: + result = run_cleanup_verifier(args, policy) + if args.receipt_file: + write_receipt(Path(args.receipt_file), result) + emit_result(result) + except (ContractError, OSError, subprocess.TimeoutExpired) as exc: + try: + validate_identity(args.trace_id, label="trace_id") + validate_identity(args.run_id, label="run_id") + validate_identity(args.work_item_id, label="work_item_id") + if args.check: + failure_terminal = "check_failed_no_write" + elif args.apply: + failure_terminal = "failed_cleanup_required" + else: + failure_terminal = "cleanup_verifier_failed_residue_unknown" + failure = receipt( + trace_id=args.trace_id, + run_id=args.run_id, + work_item_id=args.work_item_id, + phase="terminal", + terminal=failure_terminal, + detail=f"metadata_restore_drill_failed_{exc}", + ) + if args.receipt_file: + write_receipt(Path(args.receipt_file), failure) + emit_result(failure) + except (ContractError, OSError) as receipt_exc: + print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr) + print(f"ERROR={exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/backup/signoz_metadata_contract.py b/scripts/backup/signoz_metadata_contract.py new file mode 100755 index 000000000..f8907962b --- /dev/null +++ b/scripts/backup/signoz_metadata_contract.py @@ -0,0 +1,551 @@ +#!/usr/bin/env python3 +"""Shared fail-closed primitives for the SigNoz metadata backup lane. + +The contract intentionally works only through authenticated HTTP APIs. It +never opens the SigNoz SQLite database or a Docker volume and never accepts a +secret value on the command line. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import stat +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_POLICY = REPO_ROOT / "config" / "signoz" / "metadata-export-policy.json" +SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") +SAFE_ASSET_ID = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") +ALLOWED_TOKEN_MODES = {0o400, 0o600} +LOOPBACK_HOSTS = {"127.0.0.1", "::1", "localhost"} + + +class ContractError(ValueError): + """A bounded policy, transport, or artifact contract failure.""" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request( + self, + req: urllib.request.Request, + fp: Any, + code: int, + msg: str, + headers: Any, + newurl: str, + ) -> None: + return None + + +def utc_now() -> str: + return ( + datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") + ) + + +def canonical_json_bytes(value: Any) -> bytes: + return ( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + + "\n" + ).encode("utf-8") + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_json_object(path: Path, *, label: str) -> dict[str, Any]: + require_no_symlink_components(path, label=label) + try: + metadata = path.stat() + if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > 1024 * 1024: + raise ContractError(f"{label}_size_or_type_invalid") + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContractError(f"{label}_read_failed") from exc + if not isinstance(value, dict): + raise ContractError(f"{label}_not_object") + return value + + +def _normalized_key(value: str) -> str: + return re.sub(r"[^a-z0-9]", "", value.casefold()) + + +def require_no_symlink_components(path: Path, *, label: str) -> None: + if not path.is_absolute(): + path = Path.cwd() / path + current = path + while True: + try: + metadata = current.lstat() + except OSError as exc: + raise ContractError(f"{label}_path_component_unavailable") from exc + if stat.S_ISLNK(metadata.st_mode): + raise ContractError(f"{label}_symlink_component_forbidden") + if current == current.parent: + break + current = current.parent + + +def load_policy(path: Path) -> dict[str, Any]: + policy = read_json_object(path, label="policy") + if policy.get("schema") != "awoooi_signoz_metadata_export_policy_v1": + raise ContractError("unsupported_policy_schema") + + runtime = policy.get("source_runtime") + if not isinstance(runtime, dict): + raise ContractError("policy_source_runtime_missing") + if runtime.get("raw_database_access_allowed") is not False: + raise ContractError("policy_raw_database_must_be_forbidden") + if runtime.get("raw_volume_access_allowed") is not False: + raise ContractError("policy_raw_volume_must_be_forbidden") + if runtime.get("github_supply_chain_allowed") is not False: + raise ContractError("policy_github_supply_chain_must_be_forbidden") + if runtime.get("minimum_python_version") != "3.10": + raise ContractError("policy_minimum_python_version_invalid") + + limits = policy.get("limits") + if not isinstance(limits, dict): + raise ContractError("policy_limits_missing") + for key in ( + "request_timeout_seconds", + "max_response_bytes", + "max_asset_count", + "max_items_per_asset", + "max_restore_actions", + "stable_read_count", + ): + value = limits.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ContractError(f"policy_limit_invalid_{key}") + if limits["stable_read_count"] != 2: + raise ContractError("policy_requires_exactly_two_stable_reads") + + authentication = policy.get("authentication") + if not isinstance(authentication, dict): + raise ContractError("policy_authentication_missing") + if authentication.get("header_name") != "SIGNOZ-API-KEY": + raise ContractError("policy_auth_header_not_fixed") + if authentication.get("secret_value_in_arguments_allowed") is not False: + raise ContractError("policy_secret_argument_must_be_forbidden") + if authentication.get("accepted_file_modes") != ["0400", "0600"]: + raise ContractError("policy_credential_modes_invalid") + + assets = policy.get("assets") + if not isinstance(assets, list) or not assets: + raise ContractError("policy_assets_missing") + if len(assets) > limits["max_asset_count"]: + raise ContractError("policy_asset_count_exceeds_limit") + + forbidden_paths = { + item.get("path") + for item in policy.get("forbidden_endpoints", []) + if isinstance(item, dict) + } + asset_ids: set[str] = set() + asset_paths: set[str] = set() + for asset in assets: + if not isinstance(asset, dict): + raise ContractError("policy_asset_not_object") + asset_id = asset.get("id") + endpoint = asset.get("endpoint") + if not isinstance(asset_id, str) or not SAFE_ASSET_ID.fullmatch(asset_id): + raise ContractError("policy_asset_id_unsafe") + if asset_id in asset_ids: + raise ContractError(f"policy_asset_duplicate_{asset_id}") + asset_ids.add(asset_id) + if not isinstance(endpoint, str) or not endpoint.startswith("/api/v1/"): + raise ContractError(f"policy_asset_endpoint_unsafe_{asset_id}") + if "?" in endpoint or "#" in endpoint or ".." in endpoint: + raise ContractError(f"policy_asset_endpoint_unsafe_{asset_id}") + if endpoint in forbidden_paths: + raise ContractError(f"policy_forbidden_endpoint_allowlisted_{asset_id}") + if endpoint in asset_paths: + raise ContractError(f"policy_asset_endpoint_duplicate_{asset_id}") + asset_paths.add(endpoint) + if asset.get("method") != "GET": + raise ContractError(f"policy_asset_export_must_be_get_{asset_id}") + if asset.get("response_kind") not in {"collection", "document"}: + raise ContractError(f"policy_asset_response_kind_invalid_{asset_id}") + if asset.get("classification") not in {"portable", "export_only"}: + raise ContractError(f"policy_asset_classification_invalid_{asset_id}") + if asset.get("classification") == "portable": + restore = asset.get("restore") + if not isinstance(restore, dict): + raise ContractError(f"policy_restore_missing_{asset_id}") + if restore.get("method") not in {"POST", "PUT", "PATCH"}: + raise ContractError(f"policy_restore_method_invalid_{asset_id}") + if restore.get("strategy") != "collection_items": + raise ContractError(f"policy_restore_strategy_invalid_{asset_id}") + if restore.get("endpoint") != endpoint: + raise ContractError(f"policy_restore_endpoint_mismatch_{asset_id}") + if asset.get("response_kind") != "collection": + raise ContractError(f"policy_portable_asset_not_collection_{asset_id}") + + completion = policy.get("completion_contract") + if not isinstance(completion, dict): + raise ContractError("policy_completion_contract_missing") + if completion.get("portable_export_alone_is_completion") is not False: + raise ContractError("policy_export_must_not_claim_completion") + if completion.get("full_sqlite_completion_claim_allowed") is not False: + raise ContractError("policy_full_sqlite_claim_must_be_forbidden") + if completion.get("durable_terminal_receipt_required_for_apply") is not True: + raise ContractError("policy_durable_apply_receipt_must_be_required") + if completion.get("failure_terminal_receipt_required") is not True: + raise ContractError("policy_failure_receipt_must_be_required") + restore_isolation = policy.get("restore_isolation") + if not isinstance(restore_isolation, dict): + raise ContractError("policy_restore_isolation_missing") + if restore_isolation.get("image_id_sha256_required") is not True: + raise ContractError("policy_restore_image_id_must_be_required") + + forbidden_names = policy.get("forbidden_key_names") + if not isinstance(forbidden_names, list) or not forbidden_names: + raise ContractError("policy_forbidden_keys_missing") + normalized = [_normalized_key(str(item)) for item in forbidden_names] + if not all(normalized) or len(set(normalized)) != len(normalized): + raise ContractError("policy_forbidden_keys_invalid") + for pattern in policy.get("forbidden_value_patterns", []): + try: + re.compile(str(pattern)) + except re.error as exc: + raise ContractError("policy_forbidden_value_pattern_invalid") from exc + return policy + + +def validate_identity(value: str, *, label: str) -> str: + if not SAFE_ID.fullmatch(value): + raise ContractError(f"{label}_unsafe") + return value + + +def validate_base_url(value: str, *, loopback_only: bool = False) -> str: + parsed = urllib.parse.urlsplit(value) + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ContractError("base_url_contains_forbidden_components") + if parsed.path not in {"", "/"}: + raise ContractError("base_url_path_must_be_empty") + host = (parsed.hostname or "").casefold() + is_loopback = host in LOOPBACK_HOSTS + if loopback_only and not is_loopback: + raise ContractError("restore_target_must_be_loopback") + if parsed.scheme == "http" and not is_loopback: + raise ContractError("plain_http_allowed_only_for_loopback") + if parsed.scheme not in {"http", "https"} or not host: + raise ContractError("base_url_scheme_or_host_invalid") + try: + parsed.port + except ValueError as exc: + raise ContractError("base_url_port_invalid") from exc + return value.rstrip("/") + + +def validate_token_reference(path: Path, *, read_value: bool) -> str | None: + if not path.is_absolute(): + raise ContractError("credential_reference_must_be_absolute") + require_no_symlink_components(path, label="credential_reference") + try: + metadata = path.lstat() + except OSError as exc: + raise ContractError("credential_reference_unavailable") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise ContractError("credential_reference_not_regular_file") + if metadata.st_uid != os.geteuid(): + raise ContractError("credential_reference_owner_mismatch") + if stat.S_IMODE(metadata.st_mode) not in ALLOWED_TOKEN_MODES: + raise ContractError("credential_reference_mode_must_be_0400_or_0600") + if metadata.st_size <= 0 or metadata.st_size > 4096: + raise ContractError("credential_reference_size_invalid") + if not read_value: + return None + try: + raw = path.read_bytes() + except OSError as exc: + raise ContractError("credential_reference_read_failed") from exc + token = raw.rstrip(b"\r\n") + if not token or len(token) > 4096 or any(byte < 33 or byte > 126 for byte in token): + raise ContractError("credential_reference_value_shape_invalid") + return token.decode("ascii") + + +def scan_forbidden(value: Any, policy: dict[str, Any]) -> None: + forbidden_keys = { + _normalized_key(str(item)) for item in policy["forbidden_key_names"] + } + value_patterns = [ + re.compile(str(item)) for item in policy.get("forbidden_value_patterns", []) + ] + + def walk(item: Any, pointer: str) -> None: + if isinstance(item, dict): + for key, child in item.items(): + if not isinstance(key, str): + raise ContractError("json_object_key_not_string") + if _normalized_key(key) in forbidden_keys: + raise ContractError( + f"forbidden_key_detected_at_{pointer or 'root'}" + ) + walk(child, f"{pointer}/{key}") + elif isinstance(item, list): + for index, child in enumerate(item): + walk(child, f"{pointer}/{index}") + elif isinstance(item, str): + if any(pattern.search(item) for pattern in value_patterns): + raise ContractError( + f"forbidden_value_pattern_detected_at_{pointer or 'root'}" + ) + + walk(value, "") + + +def resolve_pointer(value: Any, pointer: str) -> Any: + if pointer == "": + return value + if not pointer.startswith("/"): + raise ContractError("response_pointer_invalid") + current = value + for raw_part in pointer[1:].split("/"): + part = raw_part.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict) and part in current: + current = current[part] + else: + raise ContractError("response_pointer_not_found") + return current + + +def validate_asset_document( + value: Any, + asset: dict[str, Any], + policy: dict[str, Any], +) -> int: + scan_forbidden(value, policy) + selected = resolve_pointer(value, str(asset.get("response_pointer", ""))) + if asset["response_kind"] == "collection": + if not isinstance(selected, list): + raise ContractError(f"asset_collection_shape_invalid_{asset['id']}") + if len(selected) > policy["limits"]["max_items_per_asset"]: + raise ContractError(f"asset_item_limit_exceeded_{asset['id']}") + if any(not isinstance(item, dict) for item in selected): + raise ContractError(f"asset_collection_item_invalid_{asset['id']}") + return len(selected) + if not isinstance(selected, dict): + raise ContractError(f"asset_document_shape_invalid_{asset['id']}") + return 1 + + +def semantic_items(value: Any, asset: dict[str, Any]) -> list[dict[str, Any]]: + selected = resolve_pointer(value, str(asset.get("response_pointer", ""))) + if not isinstance(selected, list): + raise ContractError(f"asset_collection_shape_invalid_{asset['id']}") + restore = asset.get("restore") + if not isinstance(restore, dict): + raise ContractError(f"asset_not_portable_{asset['id']}") + strip_fields = set(restore.get("strip_top_level_fields", [])) + normalized: list[dict[str, Any]] = [] + for item in selected: + if not isinstance(item, dict): + raise ContractError(f"asset_collection_item_invalid_{asset['id']}") + normalized.append( + {key: value for key, value in item.items() if key not in strip_fields} + ) + return sorted(normalized, key=lambda item: canonical_json_bytes(item)) + + +class ApiClient: + def __init__( + self, + *, + base_url: str, + header_name: str, + token: str, + timeout_seconds: int, + max_response_bytes: int, + ) -> None: + self.base_url = validate_base_url(base_url) + self.header_name = header_name + self.token = token + self.timeout_seconds = timeout_seconds + self.max_response_bytes = max_response_bytes + self.opener = urllib.request.build_opener(_NoRedirect()) + + def _url(self, endpoint: str) -> str: + if not endpoint.startswith("/api/v1/") or any( + item in endpoint for item in ("..", "?", "#") + ): + raise ContractError("api_endpoint_unsafe") + return f"{self.base_url}{endpoint}" + + def request_json(self, endpoint: str) -> Any: + request = urllib.request.Request( + self._url(endpoint), + method="GET", + headers={ + self.header_name: self.token, + "Accept": "application/json", + "User-Agent": "AWOOOI-P0-OBS-002-metadata-export/1", + }, + ) + try: + with self.opener.open(request, timeout=self.timeout_seconds) as response: + status_code = int(response.status) + content_type = response.headers.get_content_type() + payload = response.read(self.max_response_bytes + 1) + except urllib.error.HTTPError as exc: + raise ContractError(f"api_http_status_{exc.code}") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ContractError("api_transport_failed") from exc + if status_code != 200: + raise ContractError(f"api_http_status_{status_code}") + if content_type != "application/json": + raise ContractError("api_content_type_not_json") + if len(payload) > self.max_response_bytes: + raise ContractError("api_response_size_exceeded") + try: + return json.loads(payload) + except (UnicodeError, json.JSONDecodeError) as exc: + raise ContractError("api_response_json_invalid") from exc + + def request_write(self, method: str, endpoint: str, payload: Any) -> None: + if method not in {"POST", "PUT", "PATCH"}: + raise ContractError("api_write_method_forbidden") + body = canonical_json_bytes(payload) + if len(body) > self.max_response_bytes: + raise ContractError("api_request_size_exceeded") + request = urllib.request.Request( + self._url(endpoint), + method=method, + data=body, + headers={ + self.header_name: self.token, + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "AWOOOI-P0-OBS-002-metadata-restore/1", + }, + ) + try: + with self.opener.open(request, timeout=self.timeout_seconds) as response: + status_code = int(response.status) + response_payload = response.read(self.max_response_bytes + 1) + except urllib.error.HTTPError as exc: + raise ContractError(f"api_write_http_status_{exc.code}") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ContractError("api_write_transport_failed") from exc + if status_code not in {200, 201, 202, 204}: + raise ContractError(f"api_write_http_status_{status_code}") + if len(response_payload) > self.max_response_bytes: + raise ContractError("api_write_response_size_exceeded") + + +def receipt( + *, + trace_id: str, + run_id: str, + work_item_id: str, + phase: str, + terminal: str, + detail: str, +) -> dict[str, Any]: + return { + "schema": "awoooi_signoz_metadata_receipt_v1", + "trace_id": trace_id, + "run_id": run_id, + "work_item_id": work_item_id, + "observed_at": utc_now(), + "phase": phase, + "terminal": terminal, + "detail": detail, + } + + +def write_private(path: Path, payload: bytes) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path, flags, 0o600) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=True) as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + try: + os.close(descriptor) + except OSError: + pass + raise + + +def validate_new_private_destination(path: Path, *, label: str) -> None: + if not path.is_absolute(): + raise ContractError(f"{label}_path_must_be_absolute") + require_no_symlink_components(path.parent, label=f"{label}_parent") + try: + parent_metadata = path.parent.lstat() + except OSError as exc: + raise ContractError(f"{label}_parent_unavailable") from exc + if not stat.S_ISDIR(parent_metadata.st_mode): + raise ContractError(f"{label}_parent_not_directory") + if parent_metadata.st_uid != os.geteuid(): + raise ContractError(f"{label}_parent_owner_mismatch") + if path.exists() or path.is_symlink(): + raise ContractError(f"{label}_path_exists") + + +def write_new_private_atomic(path: Path, payload: bytes, *, label: str) -> None: + validate_new_private_destination(path, label=label) + temporary = path.with_name(f".{path.name}.partial.{os.getpid()}") + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + descriptor = os.open(temporary, flags, 0o600) + try: + with os.fdopen(descriptor, "wb", closefd=True) as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + except BaseException: + try: + os.close(descriptor) + except OSError: + pass + raise + os.replace(temporary, path) + try: + parent_descriptor = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(parent_descriptor) + finally: + os.close(parent_descriptor) + except OSError: + pass + finally: + try: + temporary.unlink() + except FileNotFoundError: + pass diff --git a/scripts/backup/tests/test_signoz_metadata_export_contract.py b/scripts/backup/tests/test_signoz_metadata_export_contract.py new file mode 100644 index 000000000..f53283dbd --- /dev/null +++ b/scripts/backup/tests/test_signoz_metadata_export_contract.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import hashlib +import json +import os +import socket +import subprocess +import sys +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any, Iterator + + +ROOT = Path(__file__).resolve().parents[3] +EXPORTER = ROOT / "scripts" / "backup" / "signoz-metadata-export.py" +VERIFIER = ROOT / "scripts" / "backup" / "verify-signoz-metadata-export.py" +RESTORE = ROOT / "scripts" / "backup" / "signoz-metadata-restore-drill.py" +POLICY = ROOT / "config" / "signoz" / "metadata-export-policy.json" +TRACE_ID = "trace-P0-OBS-002-metadata" +RUN_ID = "run-P0-OBS-002-metadata" +WORK_ITEM_ID = "P0-OBS-002" +TOKEN = "test-only-signoz-token-reference" + + +SOURCE_ASSETS: dict[str, Any] = { + "/api/v1/dashboards": {"data": [{"id": "dash-1", "title": "Ops"}]}, + "/api/v1/rules": {"data": [{"id": "rule-1", "name": "Latency"}]}, + "/api/v1/explorer/views": {"data": [{"id": "view-1", "name": "Errors"}]}, + "/api/v1/roles": {"data": [{"id": "role-1", "name": "Viewer"}]}, + "/api/v1/org/preferences": {"data": [{"name": "theme", "value": "dark"}]}, + "/api/v1/user/preferences": {"data": [{"name": "timezone", "value": "UTC"}]}, + "/api/v1/global/config": {"data": {"setupCompleted": True}}, +} + + +class MetadataHandler(BaseHTTPRequestHandler): + assets: dict[str, Any] = {} + drift_path: str | None = None + get_counts: dict[str, int] = {} + post_count = 0 + + def log_message(self, format: str, *args: object) -> None: + return + + def _authorized(self) -> bool: + return self.headers.get("SIGNOZ-API-KEY") == TOKEN + + def _send_json(self, status: int, value: Any) -> None: + payload = json.dumps(value, separators=(",", ":")).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self) -> None: + if not self._authorized(): + self._send_json(401, {"error": "unauthorized"}) + return + path = self.path.split("?", 1)[0] + if path not in self.assets: + self._send_json(404, {"error": "not found"}) + return + self.get_counts[path] = self.get_counts.get(path, 0) + 1 + value = json.loads(json.dumps(self.assets[path])) + if self.drift_path == path and self.get_counts[path] >= 2: + value["data"].append({"id": "drift", "title": "changed"}) + self._send_json(200, value) + + def do_POST(self) -> None: + if not self._authorized(): + self._send_json(401, {"error": "unauthorized"}) + return + path = self.path.split("?", 1)[0] + if path not in self.assets or not isinstance( + self.assets[path].get("data"), list + ): + self._send_json(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", "0")) + value = json.loads(self.rfile.read(length)) + if not isinstance(value, dict): + self._send_json(400, {"error": "object required"}) + return + type(self).post_count += 1 + restored = {"id": f"server-{type(self).post_count}", **value} + self.assets[path]["data"].append(restored) + self._send_json(201, {"data": restored}) + + +@contextmanager +def metadata_server( + assets: dict[str, Any], + *, + drift_path: str | None = None, +) -> Iterator[tuple[str, type[MetadataHandler]]]: + handler = type("BoundMetadataHandler", (MetadataHandler,), {}) + handler.assets = json.loads(json.dumps(assets)) + handler.drift_path = drift_path + handler.get_counts = {} + handler.post_count = 0 + server = ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + host, port = server.server_address + yield f"http://{host}:{port}", handler + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +def credential_file(tmp_path: Path, *, mode: int = 0o600) -> Path: + path = tmp_path / "signoz-api-key.ref" + path.write_text(f"{TOKEN}\n", encoding="ascii") + path.chmod(mode) + return path + + +def export_command( + *, + base_url: str, + output_dir: Path, + credential: Path | None, + apply: bool, + receipt_file: Path | None = None, +) -> list[str]: + command = [ + sys.executable, + str(EXPORTER), + "--apply" if apply else "--check", + "--base-url", + base_url, + "--output-dir", + str(output_dir), + "--trace-id", + TRACE_ID, + "--run-id", + RUN_ID, + "--work-item-id", + WORK_ITEM_ID, + ] + if credential is not None: + command.extend(["--credential-file", str(credential)]) + if apply: + terminal_path = receipt_file or output_dir.with_name( + f"{output_dir.name}.terminal.json" + ) + command.extend(["--receipt-file", str(terminal_path)]) + return command + + +def create_bundle(tmp_path: Path) -> tuple[Path, Path]: + credential = credential_file(tmp_path) + bundle = tmp_path / "bundle" + with metadata_server(SOURCE_ASSETS) as (base_url, _): + result = subprocess.run( + export_command( + base_url=base_url, + output_dir=bundle, + credential=credential, + apply=True, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 0, result.stderr + return bundle, credential + + +def isolation_receipt(tmp_path: Path, target_base_url: str) -> Path: + policy = json.loads(POLICY.read_text(encoding="utf-8")) + value = { + "schema": "awoooi_signoz_metadata_restore_isolation_v1", + "trace_id": TRACE_ID, + "run_id": RUN_ID, + "work_item_id": WORK_ITEM_ID, + "target": { + "canonical_id": f"ephemeral-signoz-metadata-restore-{RUN_ID}", + "base_url_sha256": hashlib.sha256( + target_base_url.encode("utf-8") + ).hexdigest(), + "image_ref": "signoz/signoz:v0.113.0", + "image_id": f"sha256:{'a' * 64}", + "image_present_locally": True, + "image_pull_performed": False, + "production_network_attached": False, + "production_volume_attached": False, + "ephemeral_volumes_only": True, + "cleanup_controller_armed": True, + "zero_residue_verifier_armed": True, + "network_scope": "loopback_and_internal_only", + "resource_label_key": policy["restore_isolation"]["resource_label_key"], + "resource_label_value": RUN_ID, + }, + } + path = tmp_path / "isolation.json" + path.write_text(json.dumps(value), encoding="utf-8") + path.chmod(0o600) + return path + + +def restore_command( + *, + mode: str, + bundle: Path, + target_base_url: str, + credential: Path | None, + isolation: Path, + receipt_file: Path | None = None, +) -> list[str]: + command = [ + sys.executable, + str(RESTORE), + mode, + "--bundle-dir", + str(bundle), + "--target-base-url", + target_base_url, + "--isolation-receipt", + str(isolation), + "--trace-id", + TRACE_ID, + "--run-id", + RUN_ID, + "--work-item-id", + WORK_ITEM_ID, + ] + if credential is not None: + command.extend(["--credential-file", str(credential)]) + if mode == "--apply": + terminal_path = receipt_file or bundle.parent / "restore-terminal.json" + command.extend(["--receipt-file", str(terminal_path)]) + return command + + +def test_policy_is_explicitly_non_raw_and_non_completion() -> None: + policy = json.loads(POLICY.read_text(encoding="utf-8")) + assert policy["source_runtime"]["raw_database_access_allowed"] is False + assert policy["source_runtime"]["raw_volume_access_allowed"] is False + assert policy["source_runtime"]["github_supply_chain_allowed"] is False + assert policy["completion_contract"]["portable_export_alone_is_completion"] is False + assert ( + policy["completion_contract"]["full_sqlite_completion_claim_allowed"] is False + ) + assert {item["path"] for item in policy["forbidden_endpoints"]} >= { + "/api/v1/pats", + "/api/v1/domains", + "/api/v1/channels", + } + + +def test_check_mode_writes_nothing(tmp_path: Path) -> None: + output = tmp_path / "must-not-exist" + result = subprocess.run( + export_command( + base_url="https://signoz.invalid", + output_dir=output, + credential=None, + apply=False, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["terminal"] == "check_pass_no_write" + assert not output.exists() + + +def test_apply_and_independent_verifier_pass(tmp_path: Path) -> None: + bundle, _ = create_bundle(tmp_path) + result = subprocess.run( + [ + sys.executable, + str(VERIFIER), + "--bundle-dir", + str(bundle), + "--expected-trace-id", + TRACE_ID, + "--expected-run-id", + RUN_ID, + "--expected-work-item-id", + WORK_ITEM_ID, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["terminal"] == ( + "pass_export_verified_restore_pending" + ) + manifest = json.loads((bundle / "manifest.json").read_text(encoding="utf-8")) + assert manifest["stable_read_count"] == 2 + assert manifest["raw_sqlite_read"] is False + assert manifest["sensitive_value_persisted"] is False + assert manifest["completion_claim"] is False + assert oct((bundle / "manifest.json").stat().st_mode & 0o777) == "0o600" + + +def test_secret_key_fails_closed_without_bundle(tmp_path: Path) -> None: + assets = json.loads(json.dumps(SOURCE_ASSETS)) + assets["/api/v1/global/config"]["data"]["clientSecret"] = "must-not-persist" + output = tmp_path / "bundle" + credential = credential_file(tmp_path) + with metadata_server(assets) as (base_url, _): + result = subprocess.run( + export_command( + base_url=base_url, + output_dir=output, + credential=credential, + apply=True, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 1 + assert "forbidden_key_detected" in result.stderr + assert "must-not-persist" not in result.stderr + assert not output.exists() + + +def test_source_drift_fails_closed_without_bundle(tmp_path: Path) -> None: + output = tmp_path / "bundle" + credential = credential_file(tmp_path) + with metadata_server(SOURCE_ASSETS, drift_path="/api/v1/dashboards") as ( + base_url, + _, + ): + result = subprocess.run( + export_command( + base_url=base_url, + output_dir=output, + credential=credential, + apply=True, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 1 + assert "source_metadata_drift_between_stable_reads" in result.stderr + assert not output.exists() + + +def test_tamper_breaks_independent_verifier(tmp_path: Path) -> None: + bundle, _ = create_bundle(tmp_path) + asset = bundle / "assets" / "dashboards.json" + asset.write_text('{"data":[]}\n', encoding="utf-8") + asset.chmod(0o600) + result = subprocess.run( + [ + sys.executable, + str(VERIFIER), + "--bundle-dir", + str(bundle), + "--expected-trace-id", + TRACE_ID, + "--expected-run-id", + RUN_ID, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 1 + assert "manifest_asset_hash_mismatch_dashboards" in result.stderr + + +def test_unexpected_bundle_file_breaks_independent_verifier(tmp_path: Path) -> None: + bundle, _ = create_bundle(tmp_path) + unexpected = bundle / "unlisted.json" + unexpected.write_text('{"not":"manifested"}\n', encoding="utf-8") + unexpected.chmod(0o600) + result = subprocess.run( + [ + sys.executable, + str(VERIFIER), + "--bundle-dir", + str(bundle), + "--expected-trace-id", + TRACE_ID, + "--expected-run-id", + RUN_ID, + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 1 + assert "bundle_tree_contains_unexpected_entries" in result.stderr + + +def test_restore_check_and_semantic_apply_are_bounded(tmp_path: Path) -> None: + bundle, credential = create_bundle(tmp_path) + target_assets = { + "/api/v1/dashboards": {"data": []}, + "/api/v1/rules": {"data": []}, + "/api/v1/explorer/views": {"data": []}, + } + with metadata_server(target_assets) as (target_base_url, handler): + isolation = isolation_receipt(tmp_path, target_base_url) + check_result = subprocess.run( + restore_command( + mode="--check", + bundle=bundle, + target_base_url=target_base_url, + credential=credential, + isolation=isolation, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert check_result.returncode == 0, check_result.stderr + assert json.loads(check_result.stdout)["terminal"] == "check_pass_no_write" + assert handler.post_count == 0 + + apply_result = subprocess.run( + restore_command( + mode="--apply", + bundle=bundle, + target_base_url=target_base_url, + credential=credential, + isolation=isolation, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert apply_result.returncode == 0, apply_result.stderr + assert json.loads(apply_result.stdout)["terminal"] == ( + "partial_degraded_cleanup_pending" + ) + phase_receipts = json.loads(apply_result.stdout)["phase_receipts"] + assert [item["phase"] for item in phase_receipts] == [ + "sensor_source", + "normalized_asset_identity", + "source_of_truth_diff", + "ai_decision", + "risk_policy_decision", + "check_mode", + "bounded_execution", + "independent_post_verifier", + "rollback", + "learning_writeback", + ] + assert phase_receipts[-1]["terminal"] == "pending" + assert handler.post_count == 3 + + +def test_restore_rejects_non_loopback_target_before_writes(tmp_path: Path) -> None: + bundle, credential = create_bundle(tmp_path) + target = "https://signoz.example.invalid" + isolation = isolation_receipt(tmp_path, target) + result = subprocess.run( + restore_command( + mode="--apply", + bundle=bundle, + target_base_url=target, + credential=credential, + isolation=isolation, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 1 + assert "restore_target_must_be_loopback" in result.stderr + + +def test_cleanup_verifier_requires_zero_labeled_residue(tmp_path: Path) -> None: + bundle, _ = create_bundle(tmp_path) + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + docker = fake_bin / "docker" + docker.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + docker.chmod(0o755) + + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + target = f"http://127.0.0.1:{port}" + isolation = isolation_receipt(tmp_path, target) + env = {**os.environ, "PATH": f"{fake_bin}:{os.environ['PATH']}"} + result = subprocess.run( + restore_command( + mode="--verify-cleanup", + bundle=bundle, + target_base_url=target, + credential=None, + isolation=isolation, + ), + env=env, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout)["terminal"] == "pass_zero_residue_verified" + + +def test_credential_reference_mode_fails_before_network(tmp_path: Path) -> None: + credential = credential_file(tmp_path, mode=0o644) + output = tmp_path / "bundle" + result = subprocess.run( + export_command( + base_url="https://signoz.invalid", + output_dir=output, + credential=credential, + apply=True, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 1 + assert "credential_reference_mode_must_be_0400_or_0600" in result.stderr + assert not output.exists() + + +def test_credential_symlink_fails_before_network(tmp_path: Path) -> None: + credential = credential_file(tmp_path) + link = tmp_path / "credential-link" + link.symlink_to(credential) + output = tmp_path / "bundle" + result = subprocess.run( + export_command( + base_url="https://signoz.invalid", + output_dir=output, + credential=link, + apply=True, + ), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 1 + assert "credential_reference_symlink_component_forbidden" in result.stderr + assert not output.exists() + + +def test_apply_failure_writes_terminal_receipt_without_secret(tmp_path: Path) -> None: + assets = json.loads(json.dumps(SOURCE_ASSETS)) + secret_value = "must-not-appear-in-terminal-receipt" + assets["/api/v1/global/config"]["data"]["clientSecret"] = secret_value + output = tmp_path / "bundle" + receipt_file = tmp_path / "terminal.json" + credential = credential_file(tmp_path) + with metadata_server(assets) as (base_url, _): + command = export_command( + base_url=base_url, + output_dir=output, + credential=credential, + apply=True, + receipt_file=receipt_file, + ) + result = subprocess.run( + command, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + assert result.returncode == 1 + terminal = json.loads(receipt_file.read_text(encoding="utf-8")) + assert terminal["terminal"] == "failed_export_not_closable" + assert secret_value not in receipt_file.read_text(encoding="utf-8") + assert not output.exists() diff --git a/scripts/backup/verify-signoz-metadata-export.py b/scripts/backup/verify-signoz-metadata-export.py new file mode 100755 index 000000000..ce630a618 --- /dev/null +++ b/scripts/backup/verify-signoz-metadata-export.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +"""Independently verify a SigNoz metadata export bundle.""" + +from __future__ import annotations + +import argparse +import json +import os +import stat +import sys +from pathlib import Path +from typing import Any + +from signoz_metadata_contract import ( + DEFAULT_POLICY, + SHA256, + ContractError, + canonical_json_bytes, + load_policy, + receipt, + require_no_symlink_components, + sha256_file, + validate_asset_document, + validate_identity, + write_new_private_atomic, +) + + +EXPECTED_PHASES = [ + "sensor_source", + "normalized_asset_identity", + "source_of_truth_diff", + "ai_decision", + "risk_policy_decision", + "check_mode", + "bounded_execution", + "rollback", + "terminal", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--bundle-dir", required=True) + parser.add_argument("--policy", default=str(DEFAULT_POLICY)) + parser.add_argument("--expected-trace-id", required=True) + parser.add_argument("--expected-run-id", required=True) + parser.add_argument("--expected-work-item-id", default="P0-OBS-002") + parser.add_argument("--receipt-file") + return parser.parse_args() + + +def require_private_regular(path: Path, *, label: str) -> None: + require_no_symlink_components(path, label=label) + try: + metadata = path.lstat() + except OSError as exc: + raise ContractError(f"{label}_unavailable") from exc + if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISREG(metadata.st_mode): + raise ContractError(f"{label}_not_private_regular_file") + if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o600: + raise ContractError(f"{label}_owner_or_mode_invalid") + + +def require_private_directory(path: Path, *, label: str) -> None: + require_no_symlink_components(path, label=label) + try: + metadata = path.lstat() + except OSError as exc: + raise ContractError(f"{label}_unavailable") from exc + if not stat.S_ISDIR(metadata.st_mode): + raise ContractError(f"{label}_not_directory") + if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700: + raise ContractError(f"{label}_owner_or_mode_invalid") + + +def load_bundle_json(path: Path, *, label: str) -> tuple[Any, bytes]: + require_private_regular(path, label=label) + try: + raw = path.read_bytes() + value = json.loads(raw) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContractError(f"{label}_json_invalid") from exc + if raw != canonical_json_bytes(value): + raise ContractError(f"{label}_not_canonical_json") + return value, raw + + +def validate_manifest_identity( + manifest: dict[str, Any], args: argparse.Namespace +) -> None: + expected = { + "trace_id": args.expected_trace_id, + "run_id": args.expected_run_id, + "work_item_id": args.expected_work_item_id, + } + for key, value in expected.items(): + if manifest.get(key) != value: + raise ContractError(f"manifest_{key}_mismatch") + if manifest.get("schema") != "awoooi_signoz_metadata_export_bundle_v1": + raise ContractError("manifest_schema_invalid") + if manifest.get("stable_read_count") != 2: + raise ContractError("manifest_stable_read_count_invalid") + if manifest.get("stable_read_terminal") != "pass": + raise ContractError("manifest_stable_read_not_pass") + if manifest.get("raw_sqlite_read") is not False: + raise ContractError("manifest_raw_sqlite_read_not_false") + if manifest.get("raw_volume_read") is not False: + raise ContractError("manifest_raw_volume_read_not_false") + if manifest.get("sensitive_value_persisted") is not False: + raise ContractError("manifest_secret_persistence_not_false") + if manifest.get("completion_claim") is not False: + raise ContractError("manifest_illegal_completion_claim") + source_url_hash = manifest.get("source_base_url_sha256") + if not isinstance(source_url_hash, str) or not SHA256.fullmatch(source_url_hash): + raise ContractError("manifest_source_url_hash_invalid") + if manifest.get("terminal") != ( + "portable_metadata_export_created_restore_unverified" + ): + raise ContractError("manifest_terminal_invalid") + + +def verify_assets( + bundle_dir: Path, + manifest: dict[str, Any], + policy: dict[str, Any], +) -> tuple[int, int]: + manifest_assets = manifest.get("assets") + if not isinstance(manifest_assets, list): + raise ContractError("manifest_assets_invalid") + policy_assets = policy["assets"] + if [item.get("id") for item in manifest_assets if isinstance(item, dict)] != [ + item["id"] for item in policy_assets + ]: + raise ContractError("manifest_asset_identity_or_order_mismatch") + assets_dir = bundle_dir / "assets" + require_private_directory(assets_dir, label="assets_dir") + expected_asset_files = {f"{item['id']}.json" for item in policy_assets} + if {item.name for item in assets_dir.iterdir()} != expected_asset_files: + raise ContractError("bundle_assets_tree_contains_unexpected_entries") + + total_items = 0 + portable_items = 0 + for asset, recorded in zip(policy_assets, manifest_assets, strict=True): + if not isinstance(recorded, dict): + raise ContractError("manifest_asset_entry_invalid") + asset_id = asset["id"] + expected_relative = f"assets/{asset_id}.json" + if recorded.get("file") != expected_relative: + raise ContractError(f"manifest_asset_file_mismatch_{asset_id}") + if recorded.get("endpoint") != asset["endpoint"]: + raise ContractError(f"manifest_asset_endpoint_mismatch_{asset_id}") + if recorded.get("classification") != asset["classification"]: + raise ContractError(f"manifest_asset_classification_mismatch_{asset_id}") + + asset_path = bundle_dir / "assets" / f"{asset_id}.json" + value, raw = load_bundle_json(asset_path, label=f"asset_{asset_id}") + if recorded.get("sha256") != sha256_file(asset_path): + raise ContractError(f"manifest_asset_hash_mismatch_{asset_id}") + if recorded.get("size_bytes") != len(raw): + raise ContractError(f"manifest_asset_size_mismatch_{asset_id}") + item_count = validate_asset_document(value, asset, policy) + if recorded.get("item_count") != item_count: + raise ContractError(f"manifest_asset_item_count_mismatch_{asset_id}") + total_items += item_count + if asset["classification"] == "portable": + portable_items += item_count + return total_items, portable_items + + +def verify_export_receipts( + path: Path, + args: argparse.Namespace, +) -> None: + require_private_regular(path, label="export_receipts") + try: + raw = path.read_bytes() + lines = raw.decode("utf-8").splitlines() + rows = [json.loads(line) for line in lines] + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContractError("export_receipts_invalid") from exc + if len(rows) != len(EXPECTED_PHASES): + raise ContractError("export_receipt_count_invalid") + if raw != b"".join(canonical_json_bytes(row) for row in rows): + raise ContractError("export_receipts_not_canonical_jsonl") + if [row.get("phase") for row in rows if isinstance(row, dict)] != EXPECTED_PHASES: + raise ContractError("export_receipt_phase_order_invalid") + for row in rows: + if not isinstance(row, dict): + raise ContractError("export_receipt_not_object") + if row.get("schema") != "awoooi_signoz_metadata_receipt_v1": + raise ContractError("export_receipt_schema_invalid") + if row.get("trace_id") != args.expected_trace_id: + raise ContractError("export_receipt_trace_id_mismatch") + if row.get("run_id") != args.expected_run_id: + raise ContractError("export_receipt_run_id_mismatch") + if row.get("work_item_id") != args.expected_work_item_id: + raise ContractError("export_receipt_work_item_id_mismatch") + if rows[-1].get("terminal") != "partial_degraded": + raise ContractError("export_receipt_terminal_must_remain_partial") + + +def write_receipt_file(path: Path, value: dict[str, Any]) -> None: + write_new_private_atomic( + path, + canonical_json_bytes(value), + label="verifier_receipt", + ) + + +def emit_result(value: dict[str, Any]) -> None: + print( + json.dumps( + value, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ) + ) + + +def main() -> int: + args = parse_args() + try: + validate_identity(args.expected_trace_id, label="trace_id") + validate_identity(args.expected_run_id, label="run_id") + validate_identity(args.expected_work_item_id, label="work_item_id") + policy_path = Path(args.policy) + policy = load_policy(policy_path) + bundle_dir = Path(args.bundle_dir) + if not bundle_dir.is_absolute(): + raise ContractError("bundle_dir_must_be_absolute_real_directory") + require_private_directory(bundle_dir, label="bundle_dir") + if {item.name for item in bundle_dir.iterdir()} != { + "assets", + "manifest.json", + "receipts.jsonl", + }: + raise ContractError("bundle_tree_contains_unexpected_entries") + + manifest_value, _ = load_bundle_json( + bundle_dir / "manifest.json", label="manifest" + ) + if not isinstance(manifest_value, dict): + raise ContractError("manifest_not_object") + validate_manifest_identity(manifest_value, args) + if manifest_value.get("policy_sha256") != sha256_file(policy_path): + raise ContractError("manifest_policy_hash_mismatch") + if ( + manifest_value.get("source_version") + != (policy["source_runtime"]["signoz_version"]) + ): + raise ContractError("manifest_source_version_mismatch") + total_items, portable_items = verify_assets(bundle_dir, manifest_value, policy) + coverage = manifest_value.get("coverage") + if not isinstance(coverage, dict): + raise ContractError("manifest_coverage_invalid") + portable_assets = sum( + item["classification"] == "portable" for item in policy["assets"] + ) + if coverage != { + "portable_asset_count": portable_assets, + "export_only_asset_count": len(policy["assets"]) - portable_assets, + "forbidden_endpoint_count": len(policy["forbidden_endpoints"]), + "full_sqlite_coverage": False, + }: + raise ContractError("manifest_coverage_mismatch") + verify_export_receipts(bundle_dir / "receipts.jsonl", args) + + result = receipt( + trace_id=args.expected_trace_id, + run_id=args.expected_run_id, + work_item_id=args.expected_work_item_id, + phase="independent_export_verifier", + terminal="pass_export_verified_restore_pending", + detail=( + f"asset_count_{len(policy['assets'])}_items_{total_items}_" + f"portable_items_{portable_items}_secret_scan_pass" + ), + ) + if args.receipt_file: + write_receipt_file(Path(args.receipt_file), result) + emit_result(result) + except (ContractError, OSError) as exc: + try: + validate_identity(args.expected_trace_id, label="trace_id") + validate_identity(args.expected_run_id, label="run_id") + validate_identity(args.expected_work_item_id, label="work_item_id") + failure = receipt( + trace_id=args.expected_trace_id, + run_id=args.expected_run_id, + work_item_id=args.expected_work_item_id, + phase="independent_export_verifier", + terminal="failed", + detail=f"bundle_verification_failed_{exc}", + ) + if args.receipt_file: + write_receipt_file(Path(args.receipt_file), failure) + emit_result(failure) + except (ContractError, OSError) as receipt_exc: + print(f"RECEIPT_ERROR={receipt_exc}", file=sys.stderr) + print(f"ERROR={exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ops/deploy-signoz-metadata-toolchain.sh b/scripts/ops/deploy-signoz-metadata-toolchain.sh new file mode 100755 index 000000000..d9981c188 --- /dev/null +++ b/scripts/ops/deploy-signoz-metadata-toolchain.sh @@ -0,0 +1,505 @@ +#!/usr/bin/env bash +# P0-OBS-002 - deploy an immutable, inactive SigNoz metadata toolchain to host110. +# +# The deployer never reads credentials, SQLite, or Docker volumes. It does not +# change an active pointer or invoke an authenticated metadata export. Failed +# candidates are moved into the run receipt directory rather than deleted. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TARGET_HOST="${SIGNOZ_METADATA_TARGET_HOST:-wooo@192.168.0.110}" +REMOTE_ROOT="${SIGNOZ_METADATA_REMOTE_ROOT:-/backup/toolchains/signoz-metadata}" +RECEIPT_ROOT="${SIGNOZ_METADATA_RECEIPT_ROOT:-/backup/deploy-receipts/signoz-metadata-toolchain}" +STAGE_ROOT="${SIGNOZ_METADATA_STAGE_ROOT:-/tmp}" +SSH_TIMEOUT_SECONDS="${SIGNOZ_METADATA_SSH_TIMEOUT_SECONDS:-120}" +SCP_TIMEOUT_SECONDS="${SIGNOZ_METADATA_SCP_TIMEOUT_SECONDS:-120}" +REMOTE_TIMEOUT_SECONDS="${SIGNOZ_METADATA_REMOTE_TIMEOUT_SECONDS:-60}" +KILL_AFTER_SECONDS="${SIGNOZ_METADATA_KILL_AFTER_SECONDS:-10}" +LOCAL_PYTHON_BIN="${SIGNOZ_METADATA_LOCAL_PYTHON_BIN:-python3.11}" + +LABELS=( + metadata-export-policy.json + signoz_metadata_contract.py + signoz-metadata-export.py + verify-signoz-metadata-export.py + signoz-metadata-restore-drill.py +) +LOCAL_SOURCES=( + "${ROOT_DIR}/config/signoz/metadata-export-policy.json" + "${ROOT_DIR}/scripts/backup/signoz_metadata_contract.py" + "${ROOT_DIR}/scripts/backup/signoz-metadata-export.py" + "${ROOT_DIR}/scripts/backup/verify-signoz-metadata-export.py" + "${ROOT_DIR}/scripts/backup/signoz-metadata-restore-drill.py" +) +MODES=(0644 0644 0755 0755 0755) +SSH_OPTIONS=( + -o BatchMode=yes + -o ConnectTimeout=8 + -o ConnectionAttempts=1 + -o ServerAliveInterval=5 + -o ServerAliveCountMax=2 +) + +usage() { + cat <<'USAGE' +Usage: deploy-signoz-metadata-toolchain.sh [--check|--apply] + +Required environment: + TRACE_ID Path-safe controlled-apply trace identifier + RUN_ID Unique path-safe execution identifier + WORK_ITEM_ID Must be P0-OBS-002 + +--check validates the five local sources and performs a no-write host110 diff. +--apply creates one immutable exact-hash directory. It does not create or +change an active pointer and does not run an authenticated metadata export. +USAGE +} + +MODE="${1:---check}" +case "${MODE}" in + --check|--apply) ;; + -h|--help) usage; exit 0 ;; + *) usage >&2; exit 64 ;; +esac +[ "$#" -le 1 ] || { usage >&2; exit 64; } + +TRACE_ID="${TRACE_ID:-}" +RUN_ID="${RUN_ID:-}" +WORK_ITEM_ID="${WORK_ITEM_ID:-}" + +valid_identity() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$ ]] +} + +valid_positive_integer() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +safe_absolute_path() { + local path="$1" + [[ "${path}" == /* ]] || return 1 + [[ "${path}" != / && "${path}" != */ ]] || return 1 + [[ "${path}" =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1 + case "/${path#/}/" in + *'//'*|*'/../'*|*'/./'*) return 1 ;; + esac +} + +valid_identity "${TRACE_ID}" || { echo "invalid TRACE_ID" >&2; exit 64; } +valid_identity "${RUN_ID}" || { echo "invalid RUN_ID" >&2; exit 64; } +[ "${WORK_ITEM_ID}" = "P0-OBS-002" ] || { + echo "WORK_ITEM_ID must be P0-OBS-002" >&2 + exit 64 +} +for value in "${SSH_TIMEOUT_SECONDS}" "${SCP_TIMEOUT_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" "${KILL_AFTER_SECONDS}"; do + valid_positive_integer "${value}" || { echo "invalid timeout" >&2; exit 64; } +done +for path in "${REMOTE_ROOT}" "${RECEIPT_ROOT}" "${STAGE_ROOT}"; do + safe_absolute_path "${path}" || { echo "unsafe remote path" >&2; exit 64; } +done + +bounded_ssh() { + timeout --kill-after="${KILL_AFTER_SECONDS}" "${SSH_TIMEOUT_SECONDS}" ssh "$@" +} + +bounded_scp() { + timeout --kill-after="${KILL_AFTER_SECONDS}" "${SCP_TIMEOUT_SECONDS}" scp "$@" +} + +hash_file() { + sha256sum "$1" | awk '{print $1}' +} + +emit() { + local phase="$1" terminal="$2" detail="$3" + printf '{"schema":"awoooi_controlled_apply_receipt_v1","trace_id":"%s","run_id":"%s","work_item_id":"%s","asset_id":"host110-signoz-metadata-toolchain","phase":"%s","terminal":"%s","detail":"%s"}\n' \ + "${TRACE_ID}" "${RUN_ID}" "${WORK_ITEM_ID}" "${phase}" "${terminal}" "${detail}" +} + +validate_local_sources() { + local source + command -v "${LOCAL_PYTHON_BIN}" >/dev/null 2>&1 + "${LOCAL_PYTHON_BIN}" -c 'import sys; assert sys.version_info >= (3, 10)' + for source in "${LOCAL_SOURCES[@]}"; do + [ -f "${source}" ] && [ ! -L "${source}" ] && [ -s "${source}" ] + done + "${LOCAL_PYTHON_BIN}" - "${LOCAL_SOURCES[@]:1}" <<'PY' +from pathlib import Path +import sys + +for value in sys.argv[1:]: + path = Path(value) + compile(path.read_text(encoding="utf-8"), str(path), "exec") +PY + PYTHONPATH="${ROOT_DIR}/scripts/backup" "${LOCAL_PYTHON_BIN}" - \ + "${LOCAL_SOURCES[0]}" <<'PY' +from pathlib import Path +import sys +from signoz_metadata_contract import load_policy + +policy = load_policy(Path(sys.argv[1])) +assert policy["work_item_id"] == "P0-OBS-002" +assert policy["source_runtime"]["raw_database_access_allowed"] is False +assert policy["source_runtime"]["raw_volume_access_allowed"] is False +assert policy["completion_contract"]["full_sqlite_completion_claim_allowed"] is False +PY +} + +validate_local_sources +SOURCE_HASHES=() +SOURCE_DETAIL="" +for index in "${!LOCAL_SOURCES[@]}"; do + source_hash="$(hash_file "${LOCAL_SOURCES[index]}")" + SOURCE_HASHES+=("${source_hash}") + SOURCE_DETAIL+="${LABELS[index]}_${source_hash}_" +done +SOURCE_DETAIL="${SOURCE_DETAIL%_}" +BUNDLE_ID="$(printf '%s\n' "${SOURCE_HASHES[@]}" | sha256sum | awk '{print $1}')" +TARGET_DIR="${REMOTE_ROOT}/${BUNDLE_ID}" +STAGE_DIR="${STAGE_ROOT}/awoooi-signoz-metadata-toolchain.${RUN_ID}" + +emit sensor_source pass "local_exact_sources_5" +emit normalized_asset_identity pass "bundle_${BUNDLE_ID}" +emit ai_decision pass "candidate_additive_immutable_inactive_source_deploy" +emit risk_policy_decision pass "medium_no_active_pointer_no_secret_no_raw_sqlite" + +remote_check() { + bounded_ssh "${SSH_OPTIONS[@]}" "${TARGET_HOST}" bash -s -- \ + "${REMOTE_ROOT}" "${BUNDLE_ID}" "${REMOTE_TIMEOUT_SECONDS}" \ + "${KILL_AFTER_SECONDS}" "${SOURCE_HASHES[@]}" <<'REMOTE_CHECK' +set -euo pipefail + +REMOTE_ROOT="$1" +BUNDLE_ID="$2" +REMOTE_TIMEOUT_SECONDS="$3" +KILL_AFTER_SECONDS="$4" +shift 4 +EXPECTED_HASHES=("$@") +LABELS=( + metadata-export-policy.json + signoz_metadata_contract.py + signoz-metadata-export.py + verify-signoz-metadata-export.py + signoz-metadata-restore-drill.py +) +MODES=(0644 0644 0755 0755 0755) +TARGET_DIR="${REMOTE_ROOT}/${BUNDLE_ID}" + +[ "${#EXPECTED_HASHES[@]}" -eq 5 ] +[ -d /backup ] && [ ! -L /backup ] +[ ! -e "${REMOTE_ROOT}/current" ] && [ ! -L "${REMOTE_ROOT}/current" ] +for command_name in curl docker pgrep python3 sha256sum stat timeout; do + command -v "${command_name}" >/dev/null 2>&1 +done +python3 -c 'import sys; assert sys.version_info >= (3, 10)' + +container_state="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" docker inspect --format \ + '{{.Id}}\t{{.State.Running}}\t{{.State.StartedAt}}\t{{.RestartCount}}\t{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' signoz)" +api_status="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" curl -sS -o /dev/null -w '%{http_code}' \ + http://127.0.0.1:8080/api/v1/health)" +[ "${api_status}" = "200" ] + +active_operations="$( + (pgrep -af '(^|/|[[:space:]])(backup-signoz|clickhouse-native-backup|clickhouse-native-restore-drill|run-signoz-backup-canary)[.]sh([[:space:]]|$)' || true) \ + | wc -l | tr -d ' ' +)" +[ "${active_operations}" = "0" ] + +state=absent +drift=0 +if [ -e "${TARGET_DIR}" ] || [ -L "${TARGET_DIR}" ]; then + [ -d "${TARGET_DIR}" ] && [ ! -L "${TARGET_DIR}" ] || drift=$((drift + 1)) + for index in "${!LABELS[@]}"; do + target="${TARGET_DIR}/${LABELS[index]}" + if [ ! -f "${target}" ] || [ -L "${target}" ]; then + drift=$((drift + 1)) + continue + fi + [ "$(sha256sum "${target}" | awk '{print $1}')" = "${EXPECTED_HASHES[index]}" ] || drift=$((drift + 1)) + [ "0$(stat -c '%a' "${target}")" = "${MODES[index]}" ] || drift=$((drift + 1)) + done + state=exact + [ "${drift}" -eq 0 ] || state=drift +fi + +candidate_count=0 +for candidate in "${REMOTE_ROOT}"/."${BUNDLE_ID}".candidate.*; do + [ -e "${candidate}" ] || [ -L "${candidate}" ] || continue + candidate_count=$((candidate_count + 1)) +done +[ "${candidate_count}" -eq 0 ] +[ "${drift}" -eq 0 ] +printf 'REMOTE_CHECK terminal=check_pass_no_write state=%s drift=%s candidate_count=%s api=%s active_operations=%s container_state_sha256=%s\n' \ + "${state}" "${drift}" "${candidate_count}" "${api_status}" "${active_operations}" \ + "$(printf '%s' "${container_state}" | sha256sum | awk '{print $1}')" +REMOTE_CHECK +} + +quarantine_transport_stage() { + bounded_ssh "${SSH_OPTIONS[@]}" "${TARGET_HOST}" sudo -n bash -s -- \ + "${STAGE_DIR}" "${RECEIPT_ROOT}" "${RUN_ID}" <<'REMOTE_QUARANTINE' +set -euo pipefail +stage_dir="$1" +receipt_root="$2" +run_id="$3" +receipt_dir="${receipt_root}/${run_id}" +if [ ! -e "${stage_dir}" ] && [ ! -L "${stage_dir}" ]; then + exit 0 +fi +[ -d "${stage_dir}" ] && [ ! -L "${stage_dir}" ] +mkdir -p "${receipt_root}" +if [ ! -e "${receipt_dir}" ] && [ ! -L "${receipt_dir}" ]; then + mkdir -m 0700 "${receipt_dir}" +fi +[ -d "${receipt_dir}" ] && [ ! -L "${receipt_dir}" ] +[ ! -e "${receipt_dir}/transport-stage" ] && [ ! -L "${receipt_dir}/transport-stage" ] +mv "${stage_dir}" "${receipt_dir}/transport-stage" +REMOTE_QUARANTINE +} + +if ! REMOTE_CHECK_OUTPUT="$(remote_check)"; then + emit source_of_truth_diff failed "remote_read_only_check_failed" + emit terminal failed_no_write "remote_check_failed" + exit 1 +fi +case "${REMOTE_CHECK_OUTPUT}" in + *"terminal=check_pass_no_write state=absent "*) REMOTE_STATE=absent ;; + *"terminal=check_pass_no_write state=exact "*) REMOTE_STATE=exact ;; + *) + emit source_of_truth_diff failed "remote_check_receipt_invalid" + emit terminal failed_no_write "remote_check_receipt_invalid" + exit 1 + ;; +esac +emit source_of_truth_diff pass "remote_state_${REMOTE_STATE}_drift_0" + +if [ "${MODE}" = "--check" ]; then + emit check_mode check_pass_no_write "bundle_${BUNDLE_ID}_remote_state_${REMOTE_STATE}" + emit rollback no_write "active_pointer_unchanged" + emit terminal check_pass_no_write "source_ready_remote_state_${REMOTE_STATE}" + exit 0 +fi + +if [ "${REMOTE_STATE}" = "exact" ]; then + emit check_mode pass "immutable_bundle_already_exact" + emit bounded_execution idempotent_no_write "bundle_${BUNDLE_ID}" + emit independent_post_verifier pass "remote_exact_hash_mode_api_identity" + emit rollback no_write "active_pointer_absent" + emit learning_writeback partial_degraded "runtime_export_not_executed" + emit terminal pass_source_deployed_inactive "idempotent_runtime_export_pending" + exit 0 +fi + +if ! bounded_ssh "${SSH_OPTIONS[@]}" "${TARGET_HOST}" bash -s -- \ + "${STAGE_DIR}" <<'REMOTE_STAGE'; then +set -euo pipefail +stage_dir="$1" +[ ! -e "${stage_dir}" ] && [ ! -L "${stage_dir}" ] +stage_root="${stage_dir%/*}" +[ -d "${stage_root}" ] && [ ! -L "${stage_root}" ] && [ -w "${stage_root}" ] +umask 077 +mkdir -m 0700 "${stage_dir}" +REMOTE_STAGE + emit bounded_execution failed "transport_stage_create_failed" + emit terminal failed_no_active_pointer_write "stage_create_failed" + exit 1 +fi + +for index in "${!LOCAL_SOURCES[@]}"; do + if ! bounded_scp -q "${SSH_OPTIONS[@]}" "${LOCAL_SOURCES[index]}" \ + "${TARGET_HOST}:${STAGE_DIR}/${LABELS[index]}"; then + quarantine_transport_stage >/dev/null 2>&1 || true + emit bounded_execution failed "transport_failed_index_${index}" + emit terminal failed_no_active_pointer_write "transport_stage_quarantine_attempted" + exit 1 + fi +done + +if ! REMOTE_APPLY_OUTPUT="$(bounded_ssh "${SSH_OPTIONS[@]}" "${TARGET_HOST}" \ + sudo -n bash -s -- "${TRACE_ID}" "${RUN_ID}" "${WORK_ITEM_ID}" \ + "${REMOTE_ROOT}" "${RECEIPT_ROOT}" "${STAGE_DIR}" "${BUNDLE_ID}" \ + "${REMOTE_TIMEOUT_SECONDS}" "${KILL_AFTER_SECONDS}" \ + "${SOURCE_HASHES[@]}" <<'REMOTE_APPLY' +set -euo pipefail + +TRACE_ID="$1" +RUN_ID="$2" +WORK_ITEM_ID="$3" +REMOTE_ROOT="$4" +RECEIPT_ROOT="$5" +STAGE_DIR="$6" +BUNDLE_ID="$7" +REMOTE_TIMEOUT_SECONDS="$8" +KILL_AFTER_SECONDS="$9" +shift 9 +EXPECTED_HASHES=("$@") +LABELS=( + metadata-export-policy.json + signoz_metadata_contract.py + signoz-metadata-export.py + verify-signoz-metadata-export.py + signoz-metadata-restore-drill.py +) +MODES=(0644 0644 0755 0755 0755) +TARGET_DIR="${REMOTE_ROOT}/${BUNDLE_ID}" +CANDIDATE_DIR="${REMOTE_ROOT}/.${BUNDLE_ID}.candidate.${RUN_ID}" +RECEIPT_DIR="${RECEIPT_ROOT}/${RUN_ID}" +RECEIPT_LOG="${RECEIPT_DIR}/receipts.jsonl" +DEPLOYED=0 +TERMINAL=failed + +emit_remote() { + local phase="$1" terminal="$2" detail="$3" + printf '{"schema":"awoooi_controlled_apply_receipt_v1","trace_id":"%s","run_id":"%s","work_item_id":"%s","asset_id":"host110-signoz-metadata-toolchain","phase":"%s","terminal":"%s","detail":"%s"}\n' \ + "${TRACE_ID}" "${RUN_ID}" "${WORK_ITEM_ID}" "${phase}" "${terminal}" "${detail}" | tee -a "${RECEIPT_LOG}" +} + +finalize() { + local rc=$? quarantine_terminal=not_required + trap - EXIT HUP INT TERM + set +e + if [ -d "${RECEIPT_DIR}" ] && [ ! -L "${RECEIPT_DIR}" ]; then + if [ -d "${STAGE_DIR}" ] && [ ! -L "${STAGE_DIR}" ]; then + mv "${STAGE_DIR}" "${RECEIPT_DIR}/transport-stage" + fi + if [ "${rc}" -ne 0 ]; then + quarantine_terminal=inactive_candidates_preserved + if [ -d "${CANDIDATE_DIR}" ] && [ ! -L "${CANDIDATE_DIR}" ]; then + mv "${CANDIDATE_DIR}" "${RECEIPT_DIR}/quarantine-candidate" + fi + if [ "${DEPLOYED}" -eq 1 ] && [ -d "${TARGET_DIR}" ] && [ ! -L "${TARGET_DIR}" ]; then + mv "${TARGET_DIR}" "${RECEIPT_DIR}/quarantine-target" + fi + fi + emit_remote rollback "${quarantine_terminal}" "active_pointer_never_created" + emit_remote terminal "${TERMINAL}" "exit_${rc}_runtime_export_not_executed" + fi + exit "${rc}" +} +trap finalize EXIT HUP INT TERM + +[ "${#EXPECTED_HASHES[@]}" -eq 5 ] +[ -d "${STAGE_DIR}" ] && [ ! -L "${STAGE_DIR}" ] +[ ! -e "${TARGET_DIR}" ] && [ ! -L "${TARGET_DIR}" ] +[ ! -e "${CANDIDATE_DIR}" ] && [ ! -L "${CANDIDATE_DIR}" ] +[ ! -e "${REMOTE_ROOT}/current" ] && [ ! -L "${REMOTE_ROOT}/current" ] +[ ! -e "${RECEIPT_DIR}" ] && [ ! -L "${RECEIPT_DIR}" ] +python3 -c 'import sys; assert sys.version_info >= (3, 10)' +umask 077 +mkdir -p "${REMOTE_ROOT}" "${RECEIPT_ROOT}" +mkdir -m 0700 "${RECEIPT_DIR}" "${CANDIDATE_DIR}" +: > "${RECEIPT_LOG}" +chmod 0600 "${RECEIPT_LOG}" + +exec 8>>/tmp/awoooi-signoz-backup-operation.lock +flock -n 8 +active_operations="$( + (pgrep -af '(^|/|[[:space:]])(backup-signoz|clickhouse-native-backup|clickhouse-native-restore-drill|run-signoz-backup-canary)[.]sh([[:space:]]|$)' || true) \ + | wc -l | tr -d ' ' +)" +[ "${active_operations}" = "0" ] + +runtime_before="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" docker inspect --format \ + '{{.Id}}\t{{.State.Running}}\t{{.State.StartedAt}}\t{{.RestartCount}}\t{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' signoz)" +api_before="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" curl -sS -o /dev/null -w '%{http_code}' \ + http://127.0.0.1:8080/api/v1/health)" +[ "${api_before}" = "200" ] +emit_remote sensor_source pass "staged_sources_5_runtime_api_200" + +for index in "${!LABELS[@]}"; do + staged="${STAGE_DIR}/${LABELS[index]}" + [ -f "${staged}" ] && [ ! -L "${staged}" ] + [ "$(sha256sum "${staged}" | awk '{print $1}')" = "${EXPECTED_HASHES[index]}" ] + install -o root -g root -m "${MODES[index]}" "${staged}" \ + "${CANDIDATE_DIR}/${LABELS[index]}" +done + +python3 - "${CANDIDATE_DIR}" <<'PY' +from pathlib import Path +import sys + +root = Path(sys.argv[1]) +for name in ( + "signoz_metadata_contract.py", + "signoz-metadata-export.py", + "verify-signoz-metadata-export.py", + "signoz-metadata-restore-drill.py", +): + path = root / name + compile(path.read_text(encoding="utf-8"), str(path), "exec") +PY +PYTHONPATH="${CANDIDATE_DIR}" python3 "${CANDIDATE_DIR}/signoz-metadata-export.py" \ + --check --base-url https://signoz.invalid \ + --output-dir "${RECEIPT_DIR}/offline-output-must-not-exist" \ + --policy "${CANDIDATE_DIR}/metadata-export-policy.json" \ + --trace-id "${TRACE_ID}" --run-id "${RUN_ID}" \ + --work-item-id "${WORK_ITEM_ID}" >/dev/null +[ ! -e "${RECEIPT_DIR}/offline-output-must-not-exist" ] +emit_remote check_mode pass "candidate_hash_mode_compile_policy_check" + +mv "${CANDIDATE_DIR}" "${TARGET_DIR}" +chmod 0750 "${TARGET_DIR}" +DEPLOYED=1 + +for index in "${!LABELS[@]}"; do + target="${TARGET_DIR}/${LABELS[index]}" + [ -f "${target}" ] && [ ! -L "${target}" ] + [ "$(sha256sum "${target}" | awk '{print $1}')" = "${EXPECTED_HASHES[index]}" ] + [ "0$(stat -c '%a' "${target}")" = "${MODES[index]}" ] + printf '%s\t%s\t%s\n' "${LABELS[index]}" "${EXPECTED_HASHES[index]}" "${MODES[index]}" \ + >> "${RECEIPT_DIR}/deployed-manifest.tsv" +done +chmod 0600 "${RECEIPT_DIR}/deployed-manifest.tsv" +runtime_after="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" docker inspect --format \ + '{{.Id}}\t{{.State.Running}}\t{{.State.StartedAt}}\t{{.RestartCount}}\t{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' signoz)" +api_after="$(timeout --kill-after="${KILL_AFTER_SECONDS}" \ + "${REMOTE_TIMEOUT_SECONDS}" curl -sS -o /dev/null -w '%{http_code}' \ + http://127.0.0.1:8080/api/v1/health)" +[ "${runtime_after}" = "${runtime_before}" ] +[ "${api_after}" = "200" ] +emit_remote bounded_execution pass "immutable_bundle_deployed_active_pointer_0" +emit_remote independent_post_verifier pass "hash_mode_compile_api_identity_unchanged" +emit_remote learning_writeback partial_degraded "authenticated_export_restore_pending" +TERMINAL=pass_source_deployed_inactive +REMOTE_APPLY +)"; then + quarantine_transport_stage >/dev/null 2>&1 || true + emit bounded_execution failed "remote_apply_failed_inactive_candidate_quarantined" + emit terminal failed_no_active_pointer_write "runtime_export_not_executed" + exit 1 +fi +case "${REMOTE_APPLY_OUTPUT}" in + *'"phase":"terminal","terminal":"pass_source_deployed_inactive"'*) ;; + *) + emit independent_post_verifier failed "remote_apply_terminal_invalid" + emit terminal failed_no_active_pointer_write "runtime_export_not_executed" + exit 1 + ;; +esac + +if ! POSTCHECK_OUTPUT="$(remote_check)"; then + emit independent_post_verifier failed "remote_postcheck_failed" + emit terminal failed_source_deploy_unverified "active_pointer_0" + exit 1 +fi +case "${POSTCHECK_OUTPUT}" in + *"terminal=check_pass_no_write state=exact "*) ;; + *) + emit independent_post_verifier failed "remote_postcheck_receipt_invalid" + emit terminal failed_source_deploy_unverified "active_pointer_0" + exit 1 + ;; +esac +emit check_mode pass "precheck_absent" +emit bounded_execution pass "immutable_bundle_${BUNDLE_ID}_active_pointer_0" +emit independent_post_verifier pass "postcheck_exact_hash_mode_api_identity" +emit rollback not_required "inactive_additive_bundle" +emit learning_writeback partial_degraded "runtime_export_restore_zero_residue_pending" +emit terminal pass_source_deployed_inactive "runtime_export_not_executed" diff --git a/scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py b/scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py new file mode 100644 index 000000000..b2d1d130e --- /dev/null +++ b/scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py @@ -0,0 +1,179 @@ +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +DEPLOYER = ROOT / "scripts" / "ops" / "deploy-signoz-metadata-toolchain.sh" + + +def _write_executable(path: Path, text: str) -> None: + path.write_text(text, encoding="utf-8") + path.chmod(0o755) + + +def fake_environment(tmp_path: Path, *, mode: str) -> dict[str, str]: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + event_log = tmp_path / "events.log" + ssh_count = tmp_path / "ssh.count" + event_log.touch() + ssh_count.write_text("0\n", encoding="utf-8") + + _write_executable( + fake_bin / "timeout", + """#!/bin/bash +set -eu +while [[ "${1:-}" == --kill-after=* ]]; do shift; done +shift +exec "$@" +""", + ) + _write_executable( + fake_bin / "ssh", + """#!/bin/bash +set -eu +count="$(cat "${TEST_SSH_COUNT:?}")" +count=$((count + 1)) +printf '%s\n' "$count" > "${TEST_SSH_COUNT:?}" +printf 'ssh %s\n' "$*" >> "${TEST_EVENT_LOG:?}" +cat >/dev/null || true +case "${FAKE_SSH_MODE:?}" in + check_absent) + printf 'REMOTE_CHECK terminal=check_pass_no_write state=absent drift=0 candidate_count=0 api=200 active_operations=0 container_state_sha256=%064d\n' 0 + ;; + check_drift) + exit 3 + ;; + apply) + case "$count" in + 1) + printf 'REMOTE_CHECK terminal=check_pass_no_write state=absent drift=0 candidate_count=0 api=200 active_operations=0 container_state_sha256=%064d\n' 0 + ;; + 2) ;; + 3) + printf '{"phase":"terminal","terminal":"pass_source_deployed_inactive"}\n' + ;; + 4) + printf 'REMOTE_CHECK terminal=check_pass_no_write state=exact drift=0 candidate_count=0 api=200 active_operations=0 container_state_sha256=%064d\n' 0 + ;; + *) exit 90 ;; + esac + ;; +esac +""", + ) + _write_executable( + fake_bin / "scp", + """#!/bin/bash +set -eu +printf 'scp %s\n' "$*" >> "${TEST_EVENT_LOG:?}" +""", + ) + return { + **os.environ, + "PATH": f"{fake_bin}:{os.environ['PATH']}", + "TEST_EVENT_LOG": str(event_log), + "TEST_SSH_COUNT": str(ssh_count), + "FAKE_SSH_MODE": mode, + "TRACE_ID": "trace-P0-OBS-002-metadata-deploy", + "RUN_ID": f"run-{mode}", + "WORK_ITEM_ID": "P0-OBS-002", + } + + +def run_deployer( + tmp_path: Path, *, mode: str, apply: bool +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", str(DEPLOYER), "--apply" if apply else "--check"], + env=fake_environment(tmp_path, mode=mode), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=20, + check=False, + ) + + +def receipt_rows(stdout: str) -> list[dict[str, object]]: + return [json.loads(line) for line in stdout.splitlines() if line.startswith("{")] + + +def test_help_documents_inactive_immutable_contract() -> None: + result = subprocess.run( + ["bash", str(DEPLOYER), "--help"], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=10, + check=False, + ) + assert result.returncode == 0 + assert "immutable exact-hash directory" in result.stdout + assert "does not create or" in result.stdout + assert "active pointer" in result.stdout + + +def test_check_mode_is_no_write_and_ready_when_bundle_absent(tmp_path: Path) -> None: + result = run_deployer(tmp_path, mode="check_absent", apply=False) + assert result.returncode == 0, result.stderr + rows = receipt_rows(result.stdout) + assert rows[-1]["terminal"] == "check_pass_no_write" + assert rows[-1]["detail"].endswith("remote_state_absent") + events = (tmp_path / "events.log").read_text(encoding="utf-8").splitlines() + assert len([line for line in events if line.startswith("ssh ")]) == 1 + assert not [line for line in events if line.startswith("scp ")] + + +def test_check_mode_fails_closed_on_remote_drift(tmp_path: Path) -> None: + result = run_deployer(tmp_path, mode="check_drift", apply=False) + assert result.returncode == 1 + rows = receipt_rows(result.stdout) + assert rows[-1]["terminal"] == "failed_no_write" + + +def test_apply_uses_five_transfers_and_exact_postcheck(tmp_path: Path) -> None: + result = run_deployer(tmp_path, mode="apply", apply=True) + assert result.returncode == 0, result.stderr + rows = receipt_rows(result.stdout) + assert rows[-1]["terminal"] == "pass_source_deployed_inactive" + assert rows[-1]["detail"] == "runtime_export_not_executed" + events = (tmp_path / "events.log").read_text(encoding="utf-8").splitlines() + assert len([line for line in events if line.startswith("scp ")]) == 5 + assert len([line for line in events if line.startswith("ssh ")]) == 4 + + +def test_deployer_has_no_active_pointer_or_destructive_fallback() -> None: + source = DEPLOYER.read_text(encoding="utf-8") + assert '"${REMOTE_ROOT}/current"' in source + assert "active_pointer_0" in source + assert "pass_source_deployed_inactive" in source + assert "quarantine-candidate" in source + assert "quarantine-target" in source + assert "docker stop" not in source + assert "docker start" not in source + assert "docker restart" not in source + assert "sqlite3" not in source + assert "github.com" not in source.casefold() + assert "curl |" not in source + assert "curl -fsSL" not in source + assert "\nrm " not in source + assert "ln -s" not in source + + +def test_exact_five_file_supply_chain_and_modes_are_fixed() -> None: + source = DEPLOYER.read_text(encoding="utf-8") + for path in ( + "config/signoz/metadata-export-policy.json", + "scripts/backup/signoz_metadata_contract.py", + "scripts/backup/signoz-metadata-export.py", + "scripts/backup/verify-signoz-metadata-export.py", + "scripts/backup/signoz-metadata-restore-drill.py", + ): + assert path in source + assert "MODES=(0644 0644 0755 0755 0755)" in source + assert '[ "${#EXPECTED_HASHES[@]}" -eq 5 ]' in source diff --git a/scripts/reboot-recovery/signoz-canonical-route-preflight.py b/scripts/reboot-recovery/signoz-canonical-route-preflight.py index 6694cda2a..fed7e045b 100644 --- a/scripts/reboot-recovery/signoz-canonical-route-preflight.py +++ b/scripts/reboot-recovery/signoz-canonical-route-preflight.py @@ -31,7 +31,7 @@ EXPECTED_POST_CLOSURE_BLOCKERS = [ "signoz_backup_offsite_dr_pending", "clickhouse_native_failed_artifact_retention_pending", "clickhouse_native_resume_submitted_inventory_pending", - "service_registry_runtime_mirror_reconciliation_pending", + "service_registry_production_and_live_host_readback_pending", "github_freeze_legacy_asset_retirement_pending", "monitoring_generator_duplicate_awoooi_api_identity_pending", ] @@ -46,6 +46,24 @@ TOOLCHAIN_SOURCE_PATHS = { "ops/signoz/clickhouse/restore-drill/config.d/isolated-cluster.xml" ), } +METADATA_EXPORT_SOURCE_PATHS = { + "policy_sha256": "config/signoz/metadata-export-policy.json", + "exporter_sha256": "scripts/backup/signoz-metadata-export.py", + "shared_contract_sha256": "scripts/backup/signoz_metadata_contract.py", + "independent_export_verifier_sha256": ( + "scripts/backup/verify-signoz-metadata-export.py" + ), + "isolated_restore_driver_sha256": ( + "scripts/backup/signoz-metadata-restore-drill.py" + ), + "contract_tests_sha256": ( + "scripts/backup/tests/test_signoz_metadata_export_contract.py" + ), + "controlled_deployer_sha256": ("scripts/ops/deploy-signoz-metadata-toolchain.sh"), + "deployer_tests_sha256": ( + "scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py" + ), +} HISTORICAL_PRODUCTION_TOOLCHAIN_HASHES = { "backup_orchestrator_sha256": ( "2fff0ded4ece9104b910f9e2db6deb4eebdc173d12c2be2bad2a59596e0d7520" @@ -101,6 +119,19 @@ def _current_toolchain_source_hashes(root: Path) -> dict[str, str]: return hashes +def _current_metadata_export_source_hashes(root: Path) -> dict[str, str]: + hashes: dict[str, str] = {} + for field, relative in METADATA_EXPORT_SOURCE_PATHS.items(): + source = root / relative + if not source.is_file() or source.is_symlink(): + return {} + try: + hashes[field] = _sha256_file(source) + except OSError: + return {} + return hashes + + def evaluate(root: Path, contract_path: Path) -> dict[str, Any]: contract = _load_yaml(contract_path) post_closure = contract.get("runtime_observations", {}).get( @@ -315,17 +346,20 @@ def evaluate(root: Path, contract_path: Path) -> dict[str, Any]: asset_reconciliation.get("drift_work_item_id") == "P0-OBS-002-ASSET-DRIFT-001" and asset_reconciliation.get("source_service_count") == len(source_services) - == 27 + == 28 and asset_reconciliation.get("runtime_mirror_service_count") == len(runtime_services) - == 24 + == 28 and asset_reconciliation.get("exact_shared_service_count") == len(exact_shared_services) - == 24 + == 28 and asset_reconciliation.get("shared_service_drift_count") == 0 and asset_reconciliation.get("source_only_services") == source_only_services - == ["bitan-app", "sentry", "signoz"] + == [] + and asset_reconciliation.get("source_runtime_manifest_exact") is True + and asset_reconciliation.get("production_runtime_readback_status") + == "pending_current_sha_readback" and source_services.get("signoz-clickhouse") == runtime_services.get("signoz-clickhouse") and asset_reconciliation.get("signoz_clickhouse_exact_match") is True @@ -823,6 +857,16 @@ def evaluate(root: Path, contract_path: Path) -> dict[str, Any]: sqlite_backup = pending_verifiers.get( "signoz_sqlite_application_consistent_backup", {} ) + metadata_export_source_contract = sqlite_backup.get("source_contract", {}) + declared_metadata_export_hashes = metadata_export_source_contract.get("hashes", {}) + actual_metadata_export_hashes = _current_metadata_export_source_hashes(root) + checks["signoz_metadata_export_source_hashes_bound"] = ( + metadata_export_source_contract.get("expected_file_count") + == len(METADATA_EXPORT_SOURCE_PATHS) + and set(declared_metadata_export_hashes) == set(METADATA_EXPORT_SOURCE_PATHS) + and declared_metadata_export_hashes == actual_metadata_export_hashes + and metadata_export_source_contract.get("test_terminal") == "19_passed" + ) offsite_backup = pending_verifiers.get("signoz_backup_offsite_dr", {}) failed_artifacts = pending_verifiers.get( "clickhouse_native_failed_artifact_retention", {} @@ -844,6 +888,26 @@ def evaluate(root: Path, contract_path: Path) -> dict[str, Any]: == "pending_supported_non_raw_export_or_coordinated_snapshot" and sqlite_backup.get("raw_sqlite_read_or_sync_allowed") is False and sqlite_backup.get("sqlite_cli_present_in_runtime") is False + and sqlite_backup.get("source_contract_status") + == "implemented_tested_not_deployed" + and metadata_export_source_contract.get("policy") + == "config/signoz/metadata-export-policy.json" + and metadata_export_source_contract.get("exporter") + == "scripts/backup/signoz-metadata-export.py" + and metadata_export_source_contract.get("independent_export_verifier") + == "scripts/backup/verify-signoz-metadata-export.py" + and metadata_export_source_contract.get("isolated_restore_driver") + == "scripts/backup/signoz-metadata-restore-drill.py" + and metadata_export_source_contract.get("raw_sqlite_read") is False + and metadata_export_source_contract.get("raw_volume_read") is False + and metadata_export_source_contract.get("secret_value_persistence") is False + and metadata_export_source_contract.get("full_sqlite_completion_claim") is False + and sqlite_backup.get("runtime_export_terminal") + == "pending_authenticated_stable_export" + and sqlite_backup.get("runtime_restore_terminal") + == "pending_isolated_same_version_target" + and sqlite_backup.get("runtime_zero_residue_terminal") == "pending" + and len(sqlite_backup.get("required_preconditions", [])) == 6 and offsite_backup.get("status") == "pending_offsite_snapshot_and_restore_verifier" and offsite_backup.get("current_repository_scope") @@ -974,8 +1038,27 @@ def evaluate(root: Path, contract_path: Path) -> dict[str, Any]: == "pending_durable_submitted_inventory_contract" and post_closure.get("signoz_sqlite_application_consistent_backup_status") == "pending" + and post_closure.get("signoz_metadata_export_source_contract_status") + == sqlite_backup.get("source_contract_status") + and post_closure.get("signoz_metadata_export_policy") + == sqlite_backup.get("source_contract", {}).get("policy") + and post_closure.get("signoz_metadata_exporter") + == sqlite_backup.get("source_contract", {}).get("exporter") + and post_closure.get("signoz_metadata_export_verifier") + == sqlite_backup.get("source_contract", {}).get("independent_export_verifier") + and post_closure.get("signoz_metadata_restore_driver") + == sqlite_backup.get("source_contract", {}).get("isolated_restore_driver") + and post_closure.get("signoz_metadata_controlled_deployer") + == sqlite_backup.get("source_contract", {}).get("controlled_deployer") + and post_closure.get("signoz_metadata_runtime_export_terminal") + == sqlite_backup.get("runtime_export_terminal") + and post_closure.get("signoz_metadata_runtime_restore_terminal") + == sqlite_backup.get("runtime_restore_terminal") + and post_closure.get("signoz_metadata_zero_residue_terminal") + == sqlite_backup.get("runtime_zero_residue_terminal") + and post_closure.get("signoz_metadata_full_sqlite_completion_claim") is False and post_closure.get("service_registry_runtime_mirror_status") - == "partial_degraded" + == "source_manifest_exact_production_readback_pending" and post_closure.get("service_registry_runtime_mirror_work_item_id") == "P0-OBS-002-ASSET-DRIFT-001" and post_closure.get("github_freeze_legacy_asset_status") diff --git a/scripts/reboot-recovery/tests/test_signoz_canonical_route_migration.py b/scripts/reboot-recovery/tests/test_signoz_canonical_route_migration.py index 410f5e68e..ba17d042e 100644 --- a/scripts/reboot-recovery/tests/test_signoz_canonical_route_migration.py +++ b/scripts/reboot-recovery/tests/test_signoz_canonical_route_migration.py @@ -834,6 +834,62 @@ def test_post_release_verifier_and_native_backup_close_with_remaining_gaps() -> ] assert sqlite_pending["raw_sqlite_read_or_sync_allowed"] is False assert sqlite_pending["sqlite_cli_present_in_runtime"] is False + assert sqlite_pending["source_contract_status"] == ( + "implemented_tested_not_deployed" + ) + source_contract = sqlite_pending["source_contract"] + assert source_contract["policy"] == "config/signoz/metadata-export-policy.json" + assert source_contract["exporter"] == ("scripts/backup/signoz-metadata-export.py") + assert source_contract["independent_export_verifier"] == ( + "scripts/backup/verify-signoz-metadata-export.py" + ) + assert source_contract["isolated_restore_driver"] == ( + "scripts/backup/signoz-metadata-restore-drill.py" + ) + assert source_contract["controlled_deployer"] == ( + "scripts/ops/deploy-signoz-metadata-toolchain.sh" + ) + assert source_contract["deployer_tests"] == ( + "scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py" + ) + assert source_contract["expected_file_count"] == 8 + metadata_paths = { + "policy_sha256": "config/signoz/metadata-export-policy.json", + "exporter_sha256": "scripts/backup/signoz-metadata-export.py", + "shared_contract_sha256": "scripts/backup/signoz_metadata_contract.py", + "independent_export_verifier_sha256": ( + "scripts/backup/verify-signoz-metadata-export.py" + ), + "isolated_restore_driver_sha256": ( + "scripts/backup/signoz-metadata-restore-drill.py" + ), + "contract_tests_sha256": ( + "scripts/backup/tests/test_signoz_metadata_export_contract.py" + ), + "controlled_deployer_sha256": ( + "scripts/ops/deploy-signoz-metadata-toolchain.sh" + ), + "deployer_tests_sha256": ( + "scripts/ops/tests/test_signoz_metadata_toolchain_deploy.py" + ), + } + assert source_contract["hashes"] == { + field: hashlib.sha256((ROOT / relative).read_bytes()).hexdigest() + for field, relative in metadata_paths.items() + } + assert source_contract["test_terminal"] == "19_passed" + assert source_contract["raw_sqlite_read"] is False + assert source_contract["raw_volume_read"] is False + assert source_contract["secret_value_persistence"] is False + assert source_contract["full_sqlite_completion_claim"] is False + assert sqlite_pending["runtime_export_terminal"] == ( + "pending_authenticated_stable_export" + ) + assert sqlite_pending["runtime_restore_terminal"] == ( + "pending_isolated_same_version_target" + ) + assert sqlite_pending["runtime_zero_residue_terminal"] == "pending" + assert len(sqlite_pending["required_preconditions"]) == 6 offsite = receipt["pending_verifiers"]["signoz_backup_offsite_dr"] assert offsite["current_repository_scope"] == "local_same_failure_domain" assert offsite["offsite_verified"] is False @@ -877,11 +933,15 @@ def test_post_release_verifier_and_native_backup_close_with_remaining_gaps() -> ) assets = receipt["asset_reconciliation"] assert assets["drift_work_item_id"] == "P0-OBS-002-ASSET-DRIFT-001" - assert assets["source_service_count"] == 27 - assert assets["runtime_mirror_service_count"] == 24 - assert assets["exact_shared_service_count"] == 24 + assert assets["source_service_count"] == 28 + assert assets["runtime_mirror_service_count"] == 28 + assert assets["exact_shared_service_count"] == 28 assert assets["shared_service_drift_count"] == 0 - assert assets["source_only_services"] == ["bitan-app", "sentry", "signoz"] + assert assets["source_only_services"] == [] + assert assets["source_runtime_manifest_exact"] is True + assert assets["production_runtime_readback_status"] == ( + "pending_current_sha_readback" + ) assert assets["signoz_clickhouse_exact_match"] is True assert assets["live_signoz_query_host"] == "192.168.0.110" assert assets["source_signoz_host"] == "192.168.0.188" @@ -905,7 +965,7 @@ def test_post_release_verifier_and_native_backup_close_with_remaining_gaps() -> "signoz_backup_offsite_dr_pending", "clickhouse_native_failed_artifact_retention_pending", "clickhouse_native_resume_submitted_inventory_pending", - "service_registry_runtime_mirror_reconciliation_pending", + "service_registry_production_and_live_host_readback_pending", "github_freeze_legacy_asset_retirement_pending", "monitoring_generator_duplicate_awoooi_api_identity_pending", ] @@ -1002,8 +1062,34 @@ def test_post_closure_contract_mirror_and_program_blockers_are_consistent() -> N assert ( post_closure["signoz_sqlite_application_consistent_backup_status"] == "pending" ) + assert post_closure["signoz_metadata_export_source_contract_status"] == ( + "implemented_tested_not_deployed" + ) + assert post_closure["signoz_metadata_export_policy"] == ( + "config/signoz/metadata-export-policy.json" + ) + assert post_closure["signoz_metadata_exporter"] == ( + "scripts/backup/signoz-metadata-export.py" + ) + assert post_closure["signoz_metadata_export_verifier"] == ( + "scripts/backup/verify-signoz-metadata-export.py" + ) + assert post_closure["signoz_metadata_restore_driver"] == ( + "scripts/backup/signoz-metadata-restore-drill.py" + ) + assert post_closure["signoz_metadata_controlled_deployer"] == ( + "scripts/ops/deploy-signoz-metadata-toolchain.sh" + ) + assert post_closure["signoz_metadata_runtime_export_terminal"] == ( + "pending_authenticated_stable_export" + ) + assert post_closure["signoz_metadata_runtime_restore_terminal"] == ( + "pending_isolated_same_version_target" + ) + assert post_closure["signoz_metadata_zero_residue_terminal"] == "pending" + assert post_closure["signoz_metadata_full_sqlite_completion_claim"] is False assert post_closure["service_registry_runtime_mirror_status"] == ( - "partial_degraded" + "source_manifest_exact_production_readback_pending" ) assert post_closure["service_registry_runtime_mirror_work_item_id"] == ( "P0-OBS-002-ASSET-DRIFT-001"