254 lines
9.3 KiB
Python
Executable File
254 lines
9.3 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Classify MOMO daily-sales source arrival from a read-only preflight log.
|
|
|
|
This parser never connects to MOMO, never imports files, never moves Drive
|
|
artifacts, and never authorizes DB / host / Drive writes. It turns the existing
|
|
`momo-drive-token-source-recovery-preflight.sh` evidence into a compact gate so
|
|
operators can tell whether they should keep waiting for a legitimate source or
|
|
start a separate safe-import preflight.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
EXPECTED_IMPORT_CONFIG = "當日業績匯入|即時業績_當日"
|
|
SUMMARY_RE = re.compile(
|
|
r"^MOMO_DRIVE_TOKEN_SOURCE_PREFLIGHT "
|
|
r"PASS=(?P<pass>\d+) WARN=(?P<warn>\d+) BLOCKED=(?P<blocked>\d+) "
|
|
r"HOST=(?P<host>\S+) FRESHNESS_MAX_DAYS=(?P<freshness_max_days>\d+)"
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Classify MOMO source-arrival readiness from preflight output.",
|
|
)
|
|
parser.add_argument(
|
|
"--preflight-log",
|
|
required=True,
|
|
help="Path to momo-drive-token-source-recovery-preflight output, or '-' for stdin.",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="Print JSON result.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def load_text(source: str) -> str:
|
|
if source == "-":
|
|
return sys.stdin.read()
|
|
return Path(source).read_text(encoding="utf-8")
|
|
|
|
|
|
def parse_int(value: Any, default: int | None = None) -> int | None:
|
|
try:
|
|
return int(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
def parse_pipe(value: str, expected_parts: int) -> list[str]:
|
|
parts = str(value or "").split("|")
|
|
if len(parts) < expected_parts:
|
|
parts.extend([""] * (expected_parts - len(parts)))
|
|
return parts[:expected_parts]
|
|
|
|
|
|
def parse_preflight(text: str) -> dict[str, Any]:
|
|
values: dict[str, str] = {}
|
|
messages = {"ok": [], "warn": [], "blocked": []}
|
|
summary: dict[str, Any] = {}
|
|
|
|
for raw_line in text.splitlines():
|
|
line = raw_line.strip()
|
|
if not line:
|
|
continue
|
|
summary_match = SUMMARY_RE.match(line)
|
|
if summary_match:
|
|
summary = {
|
|
key: parse_int(value) if key != "host" else value
|
|
for key, value in summary_match.groupdict().items()
|
|
}
|
|
continue
|
|
if line.startswith("OK: "):
|
|
messages["ok"].append(line[4:])
|
|
continue
|
|
if line.startswith("WARN: "):
|
|
messages["warn"].append(line[6:])
|
|
continue
|
|
if line.startswith("BLOCKED: "):
|
|
messages["blocked"].append(line[9:])
|
|
continue
|
|
if re.match(r"^[A-Z][A-Z0-9_]+(?:\s|$)", line):
|
|
key, _, value = line.partition(" ")
|
|
values[key] = value.strip()
|
|
|
|
return {"values": values, "messages": messages, "summary": summary}
|
|
|
|
|
|
def monthly_sync_ok(value: str) -> bool:
|
|
snapshot_count, monthly_count, dmin, dmax, mmin, mmax = parse_pipe(value, 6)
|
|
snapshot_n = parse_int(snapshot_count, 0) or 0
|
|
return (
|
|
snapshot_n > 0
|
|
and snapshot_count == monthly_count
|
|
and bool(dmin)
|
|
and bool(dmax)
|
|
and dmin == mmin
|
|
and dmax == mmax
|
|
)
|
|
|
|
|
|
def latest_import_clean(value: str) -> bool:
|
|
job_id, status, _file_name, _created, _completed, total, success, errors = parse_pipe(
|
|
value, 8
|
|
)
|
|
return (
|
|
parse_int(job_id) is not None
|
|
and status == "completed"
|
|
and parse_int(total, -1) == parse_int(success, -2)
|
|
and parse_int(errors, -1) == 0
|
|
)
|
|
|
|
|
|
def classify(parsed: dict[str, Any]) -> dict[str, Any]:
|
|
values = parsed["values"]
|
|
summary = parsed["summary"]
|
|
messages = parsed["messages"]
|
|
|
|
freshness_days_text, latest_daily_date = parse_pipe(values.get("DB_DAILY_FRESHNESS", ""), 2)
|
|
freshness_days = parse_int(freshness_days_text)
|
|
freshness_max_days = parse_int(summary.get("freshness_max_days"), 2) or 2
|
|
drive_intake_count = parse_int(values.get("DRIVE_INTAKE_COUNT"), 0) or 0
|
|
drive_failed_count = parse_int(values.get("DRIVE_FAILED_COUNT"), 0) or 0
|
|
drive_archive_latest = values.get("DRIVE_ARCHIVE_LATEST_MODIFIED", "none") or "none"
|
|
drive_global_latest = values.get("DRIVE_GLOBAL_LATEST_MODIFIED", "none") or "none"
|
|
|
|
service_ready = (
|
|
values.get("MOMO_PUBLIC_HEALTH_CODE") == "200"
|
|
and values.get("MOMO_HEALTH_CODE") == "200"
|
|
and values.get("MOMO_APP_HEALTH") == "healthy"
|
|
and values.get("SCHEDULER_RUNNING") == "true"
|
|
and values.get("SCHEDULER_HEALTH") == "healthy"
|
|
)
|
|
import_config_ok = EXPECTED_IMPORT_CONFIG in values.get("IMPORT_CONFIG", "")
|
|
sync_ok = monthly_sync_ok(values.get("DB_MONTHLY_SYNC", ""))
|
|
clean_import = latest_import_clean(values.get("DB_LATEST_DAILY_IMPORT_JOB", ""))
|
|
freshness_green = (
|
|
freshness_days is not None and 0 <= freshness_days <= freshness_max_days
|
|
)
|
|
freshness_stale = freshness_days is not None and freshness_days > freshness_max_days
|
|
|
|
blockers: list[str] = []
|
|
warnings: list[str] = []
|
|
status = "blocked_preflight_evidence_incomplete"
|
|
next_step = "rerun_momo_drive_token_source_recovery_preflight"
|
|
safe_import_preflight_allowed = False
|
|
exit_code = 2
|
|
|
|
if not summary:
|
|
blockers.append("preflight_summary_missing")
|
|
if not service_ready:
|
|
blockers.append("momo_service_or_scheduler_not_ready")
|
|
if not import_config_ok:
|
|
blockers.append("drive_import_config_not_expected_intake")
|
|
if not sync_ok:
|
|
blockers.append("current_month_snapshot_realtime_sync_not_proven")
|
|
if drive_failed_count > 0:
|
|
warnings.append("drive_failed_folder_has_matching_candidates")
|
|
|
|
if blockers:
|
|
status = "blocked_service_or_evidence_not_ready"
|
|
next_step = "repair_readonly_preflight_evidence_before_source_or_import_decision"
|
|
elif freshness_green:
|
|
status = "freshness_already_green_recheck_cold_start"
|
|
next_step = "rerun_post_reboot_readiness_summary_with_same_evidence_chain"
|
|
exit_code = 0
|
|
elif drive_intake_count > 0 and freshness_stale:
|
|
status = "source_arrived_ready_for_safe_import_preflight"
|
|
next_step = "run_owner_approved_safe_import_preflight_no_db_or_drive_write_yet"
|
|
safe_import_preflight_allowed = True
|
|
exit_code = 0
|
|
elif drive_intake_count > 0:
|
|
status = "source_arrived_freshness_unknown_recheck_before_import"
|
|
next_step = "rerun_momo_preflight_and_validate_freshness_before_import"
|
|
safe_import_preflight_allowed = True
|
|
exit_code = 1
|
|
elif freshness_stale:
|
|
status = "blocked_source_absent_fail_closed"
|
|
next_step = "wait_for_legitimate_daily_sales_source_then_rerun_gate"
|
|
else:
|
|
status = "blocked_freshness_unknown_fail_closed"
|
|
next_step = "rerun_preflight_or_repair_readonly_freshness_readback"
|
|
|
|
if not clean_import:
|
|
warnings.append("latest_daily_import_job_not_clean_completed")
|
|
|
|
return {
|
|
"schema_version": "momo_source_arrival_gate_v1",
|
|
"status": status,
|
|
"exit_code": exit_code,
|
|
"next_step": next_step,
|
|
"safe_import_preflight_allowed": safe_import_preflight_allowed,
|
|
"runtime_write_authorized": False,
|
|
"db_write_authorized": False,
|
|
"drive_move_authorized": False,
|
|
"manual_import_authorized": False,
|
|
"secret_value_collection_allowed": False,
|
|
"service_ready": service_ready,
|
|
"import_config_ok": import_config_ok,
|
|
"current_month_sync_ok": sync_ok,
|
|
"latest_import_clean": clean_import,
|
|
"freshness_days": freshness_days,
|
|
"freshness_latest_date": latest_daily_date or "unknown",
|
|
"freshness_max_days": freshness_max_days,
|
|
"drive_intake_count": drive_intake_count,
|
|
"drive_archive_latest_modified": drive_archive_latest,
|
|
"drive_global_latest_modified": drive_global_latest,
|
|
"drive_failed_count": drive_failed_count,
|
|
"preflight_pass": summary.get("pass", 0),
|
|
"preflight_warn": summary.get("warn", len(messages["warn"])),
|
|
"preflight_blocked": summary.get("blocked", len(messages["blocked"])),
|
|
"blockers": blockers,
|
|
"warnings": warnings,
|
|
"no_false_green_rules": [
|
|
"source_arrived_does_not_authorize_import",
|
|
"safe_import_preflight_allowed_does_not_authorize_db_write",
|
|
"freshness_green_requires_post_reboot_summary_recheck",
|
|
"archive_or_local_old_file_does_not_count_as_new_source",
|
|
],
|
|
}
|
|
|
|
|
|
def print_human(result: dict[str, Any]) -> None:
|
|
print(
|
|
"MOMO_SOURCE_ARRIVAL_GATE "
|
|
f"status={result['status']} "
|
|
f"source_intake={result['drive_intake_count']} "
|
|
f"freshness={result['freshness_days']}|{result['freshness_latest_date']} "
|
|
f"safe_import_preflight_allowed={int(result['safe_import_preflight_allowed'])} "
|
|
"runtime_write_authorized=0 "
|
|
"db_write_authorized=0 "
|
|
"drive_move_authorized=0 "
|
|
f"next_step={result['next_step']}"
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
result = classify(parse_preflight(load_text(args.preflight_log)))
|
|
if args.json:
|
|
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
|
else:
|
|
print_human(result)
|
|
return int(result["exit_code"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|