544 lines
20 KiB
Bash
Executable File
544 lines
20 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# Delta sync + validation starting from a baseline snapshot DB.
|
||
#
|
||
# This script:
|
||
# 1) Copies BASE_DB_DIR -> DELTA_DB_DIR (so baseline is not modified)
|
||
# 2) Runs rpki validation again (RRDP will prefer delta if available)
|
||
# 3) Writes artifacts + a markdown delta analysis report
|
||
#
|
||
# Usage:
|
||
# ./scripts/manual_sync/delta_sync.sh <base_db_dir> <base_report_json>
|
||
#
|
||
# Outputs under OUT_DIR (default: target/live/manual_sync):
|
||
# - *_delta_db_* copied RocksDB directory
|
||
# - *_delta_report_*.json audit report
|
||
# - *_delta_run_*.log stdout/stderr log (includes summary)
|
||
# - *_delta_db_stats_*.txt db_stats --exact output
|
||
# - *_delta_rrdp_state_*.tsv rrdp_state_dump --view legacy-state output
|
||
# - *_delta_analysis_*.md base vs delta comparison report
|
||
|
||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||
cd "$ROOT_DIR"
|
||
|
||
BASE_DB_DIR="${1:-}"
|
||
BASE_REPORT_JSON="${2:-}"
|
||
if [[ -z "${BASE_DB_DIR}" || -z "${BASE_REPORT_JSON}" ]]; then
|
||
echo "Usage: $0 <base_db_dir> <base_report_json>" >&2
|
||
exit 2
|
||
fi
|
||
if [[ ! -d "${BASE_DB_DIR}" ]]; then
|
||
echo "ERROR: base_db_dir is not a directory: ${BASE_DB_DIR}" >&2
|
||
exit 2
|
||
fi
|
||
if [[ ! -f "${BASE_REPORT_JSON}" ]]; then
|
||
echo "ERROR: base_report_json does not exist: ${BASE_REPORT_JSON}" >&2
|
||
exit 2
|
||
fi
|
||
|
||
TAL_URL="${TAL_URL:-https://tal.apnic.net/tal-archive/apnic-rfc7730-https.tal}"
|
||
HTTP_TIMEOUT_SECS="${HTTP_TIMEOUT_SECS:-1800}"
|
||
RSYNC_TIMEOUT_SECS="${RSYNC_TIMEOUT_SECS:-1800}"
|
||
RSYNC_MIRROR_ROOT="${RSYNC_MIRROR_ROOT:-}"
|
||
VALIDATION_TIME="${VALIDATION_TIME:-}"
|
||
|
||
OUT_DIR="${OUT_DIR:-$ROOT_DIR/target/live/manual_sync}"
|
||
mkdir -p "$OUT_DIR"
|
||
|
||
TS="$(date -u +%Y%m%dT%H%M%SZ)"
|
||
RUN_NAME="${RUN_NAME:-apnic_delta_${TS}}"
|
||
|
||
DELTA_DB_DIR="${DELTA_DB_DIR:-$OUT_DIR/${RUN_NAME}_db}"
|
||
DELTA_REPORT_JSON="${DELTA_REPORT_JSON:-$OUT_DIR/${RUN_NAME}_report.json}"
|
||
DELTA_RUN_LOG="${DELTA_RUN_LOG:-$OUT_DIR/${RUN_NAME}_run.log}"
|
||
|
||
BASE_DB_STATS_TXT="${BASE_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_base_db_stats.txt}"
|
||
DELTA_DB_STATS_TXT="${DELTA_DB_STATS_TXT:-$OUT_DIR/${RUN_NAME}_delta_db_stats.txt}"
|
||
|
||
BASE_RRDP_STATE_TSV="${BASE_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_base_rrdp_state.tsv}"
|
||
DELTA_RRDP_STATE_TSV="${DELTA_RRDP_STATE_TSV:-$OUT_DIR/${RUN_NAME}_delta_rrdp_state.tsv}"
|
||
|
||
DELTA_ANALYSIS_MD="${DELTA_ANALYSIS_MD:-$OUT_DIR/${RUN_NAME}_delta_analysis.md}"
|
||
DELTA_META_JSON="${DELTA_META_JSON:-$OUT_DIR/${RUN_NAME}_meta.json}"
|
||
|
||
# Best-effort base meta discovery (produced by `full_sync.sh`).
|
||
BASE_META_JSON="${BASE_META_JSON:-}"
|
||
if [[ -z "${BASE_META_JSON}" ]]; then
|
||
guess="${BASE_REPORT_JSON%_report.json}_meta.json"
|
||
if [[ -f "${guess}" ]]; then
|
||
BASE_META_JSON="${guess}"
|
||
fi
|
||
fi
|
||
|
||
echo "== rpki manual delta sync ==" >&2
|
||
echo "tal_url=$TAL_URL" >&2
|
||
echo "base_db=$BASE_DB_DIR" >&2
|
||
echo "base_report=$BASE_REPORT_JSON" >&2
|
||
echo "delta_db=$DELTA_DB_DIR" >&2
|
||
echo "delta_report=$DELTA_REPORT_JSON" >&2
|
||
|
||
echo "== copying base DB (baseline is not modified) ==" >&2
|
||
cp -a "$BASE_DB_DIR" "$DELTA_DB_DIR"
|
||
|
||
script_start_s="$(date +%s)"
|
||
run_start_s="$(date +%s)"
|
||
cmd=(cargo run --release --bin rpki -- \
|
||
--db "$DELTA_DB_DIR" \
|
||
--tal-url "$TAL_URL" \
|
||
--http-timeout-secs "$HTTP_TIMEOUT_SECS" \
|
||
--rsync-timeout-secs "$RSYNC_TIMEOUT_SECS" \
|
||
--report-json "$DELTA_REPORT_JSON")
|
||
if [[ -n "${RSYNC_MIRROR_ROOT}" ]]; then
|
||
cmd+=(--rsync-mirror-root "$RSYNC_MIRROR_ROOT")
|
||
fi
|
||
if [[ -n "${VALIDATION_TIME}" ]]; then
|
||
cmd+=(--validation-time "$VALIDATION_TIME")
|
||
fi
|
||
|
||
(
|
||
echo "# command:"
|
||
printf '%q ' "${cmd[@]}"
|
||
echo
|
||
echo
|
||
"${cmd[@]}"
|
||
) 2>&1 | tee "$DELTA_RUN_LOG" >/dev/null
|
||
run_end_s="$(date +%s)"
|
||
run_duration_s="$((run_end_s - run_start_s))"
|
||
|
||
echo "== db_stats (exact) ==" >&2
|
||
db_stats_start_s="$(date +%s)"
|
||
cargo run --release --bin db_stats -- --db "$BASE_DB_DIR" --exact 2>&1 | tee "$BASE_DB_STATS_TXT" >/dev/null
|
||
cargo run --release --bin db_stats -- --db "$DELTA_DB_DIR" --exact 2>&1 | tee "$DELTA_DB_STATS_TXT" >/dev/null
|
||
db_stats_end_s="$(date +%s)"
|
||
db_stats_duration_s="$((db_stats_end_s - db_stats_start_s))"
|
||
|
||
echo "== rrdp_state_dump (legacy-state) ==" >&2
|
||
state_start_s="$(date +%s)"
|
||
cargo run --release --bin rrdp_state_dump -- --db "$BASE_DB_DIR" --view legacy-state >"$BASE_RRDP_STATE_TSV"
|
||
cargo run --release --bin rrdp_state_dump -- --db "$DELTA_DB_DIR" --view legacy-state >"$DELTA_RRDP_STATE_TSV"
|
||
state_end_s="$(date +%s)"
|
||
state_duration_s="$((state_end_s - state_start_s))"
|
||
|
||
script_end_s="$(date +%s)"
|
||
total_duration_s="$((script_end_s - script_start_s))"
|
||
|
||
echo "== delta analysis report ==" >&2
|
||
TAL_URL="$TAL_URL" \
|
||
BASE_DB_DIR="$BASE_DB_DIR" \
|
||
DELTA_DB_DIR="$DELTA_DB_DIR" \
|
||
DELTA_RUN_LOG="$DELTA_RUN_LOG" \
|
||
VALIDATION_TIME_ARG="$VALIDATION_TIME" \
|
||
HTTP_TIMEOUT_SECS="$HTTP_TIMEOUT_SECS" \
|
||
RSYNC_TIMEOUT_SECS="$RSYNC_TIMEOUT_SECS" \
|
||
RUN_DURATION_S="$run_duration_s" \
|
||
DB_STATS_DURATION_S="$db_stats_duration_s" \
|
||
STATE_DURATION_S="$state_duration_s" \
|
||
TOTAL_DURATION_S="$total_duration_s" \
|
||
python3 - "$BASE_REPORT_JSON" "$DELTA_REPORT_JSON" "$BASE_RRDP_STATE_TSV" "$DELTA_RRDP_STATE_TSV" \
|
||
"$BASE_DB_STATS_TXT" "$DELTA_DB_STATS_TXT" "$BASE_META_JSON" "$DELTA_META_JSON" "$DELTA_ANALYSIS_MD" <<'PY'
|
||
import json
|
||
import os
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
|
||
base_report_path = Path(sys.argv[1])
|
||
delta_report_path = Path(sys.argv[2])
|
||
base_state_path = Path(sys.argv[3])
|
||
delta_state_path = Path(sys.argv[4])
|
||
base_db_stats_path = Path(sys.argv[5])
|
||
delta_db_stats_path = Path(sys.argv[6])
|
||
base_meta_path_s = sys.argv[7]
|
||
delta_meta_path = Path(sys.argv[8])
|
||
out_md_path = Path(sys.argv[9])
|
||
|
||
def load_json(p: Path):
|
||
s = p.read_text(encoding="utf-8")
|
||
try:
|
||
return json.loads(s)
|
||
except json.JSONDecodeError:
|
||
# Backwards-compat / robustness: tolerate accidental literal trailing "\\n".
|
||
s2 = s.strip()
|
||
if s2.endswith("\\n"):
|
||
s2 = s2[:-2].rstrip()
|
||
return json.loads(s2)
|
||
|
||
def load_optional_json(path_s: str):
|
||
if not path_s:
|
||
return None
|
||
p = Path(path_s)
|
||
if not p.exists():
|
||
return None
|
||
return load_json(p)
|
||
|
||
def parse_rrdp_state_tsv(p: Path):
|
||
# format from `rrdp_state_dump --view legacy-state`:
|
||
# [legacy-state]
|
||
# notify_uri serial session_id
|
||
# <notify_uri> <serial> <session_id>
|
||
out = {}
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("["):
|
||
continue
|
||
if line == "notify_uri serial session_id":
|
||
continue
|
||
parts = line.split(" ")
|
||
if len(parts) != 3:
|
||
raise SystemExit(f"invalid rrdp_state_dump line in {p}: {line!r}")
|
||
uri, serial, session = parts
|
||
out[uri] = (int(serial), session)
|
||
return out
|
||
|
||
def parse_db_stats(p: Path):
|
||
# lines: key=value
|
||
out = {}
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
if "=" not in line:
|
||
continue
|
||
k, v = line.split("=", 1)
|
||
k = k.strip()
|
||
v = v.strip()
|
||
if v.isdigit():
|
||
out[k] = int(v)
|
||
else:
|
||
out[k] = v
|
||
return out
|
||
|
||
def warnings_total(rep: dict) -> int:
|
||
return len(rep["tree"]["warnings"]) + sum(len(pp["warnings"]) for pp in rep["publication_points"])
|
||
|
||
def report_summary(rep: dict) -> dict:
|
||
return {
|
||
"validation_time": rep["meta"]["validation_time_rfc3339_utc"],
|
||
"publication_points_processed": rep["tree"]["instances_processed"],
|
||
"publication_points_failed": rep["tree"]["instances_failed"],
|
||
"rrdp_repos_unique": len({pp.get("rrdp_notification_uri") for pp in rep["publication_points"] if pp.get("rrdp_notification_uri")}),
|
||
"vrps": len(rep["vrps"]),
|
||
"aspas": len(rep["aspas"]),
|
||
"audit_publication_points": len(rep["publication_points"]),
|
||
"warnings_total": warnings_total(rep),
|
||
}
|
||
|
||
def count_repo_sync_failed(rep: dict) -> int:
|
||
# Best-effort heuristic (we don't currently expose a structured counter in the audit report).
|
||
# Keep the match conservative to avoid false positives.
|
||
def is_repo_sync_failed(msg: str) -> bool:
|
||
m = msg.lower()
|
||
return "repo sync failed" in m or "rrdp fetch failed" in m or "rsync fetch failed" in m
|
||
|
||
n = 0
|
||
for w in rep["tree"]["warnings"]:
|
||
if is_repo_sync_failed(w.get("message", "")):
|
||
n += 1
|
||
for pp in rep["publication_points"]:
|
||
for w in pp.get("warnings", []):
|
||
if is_repo_sync_failed(w.get("message", "")):
|
||
n += 1
|
||
return n
|
||
|
||
def pp_manifest_sha(pp: dict) -> str:
|
||
# In our audit format, the first object is the manifest (synthetic entry) with sha256 of manifest_bytes.
|
||
for o in pp["objects"]:
|
||
if o["kind"] == "manifest":
|
||
return o["sha256_hex"]
|
||
return ""
|
||
|
||
def pp_objects_by_uri(rep: dict):
|
||
m = {}
|
||
for pp in rep["publication_points"]:
|
||
for o in pp["objects"]:
|
||
m[o["rsync_uri"]] = (o["sha256_hex"], o["kind"])
|
||
return m
|
||
|
||
def vrp_set(rep: dict):
|
||
return {(v["asn"], v["prefix"], v["max_length"]) for v in rep["vrps"]}
|
||
|
||
def rfc_refs_str(w: dict) -> str:
|
||
refs = w.get("rfc_refs") or []
|
||
return ", ".join(refs) if refs else ""
|
||
|
||
base = load_json(base_report_path)
|
||
delta = load_json(delta_report_path)
|
||
base_sum = report_summary(base)
|
||
delta_sum = report_summary(delta)
|
||
|
||
base_db = parse_db_stats(base_db_stats_path)
|
||
delta_db = parse_db_stats(delta_db_stats_path)
|
||
|
||
base_state = parse_rrdp_state_tsv(base_state_path)
|
||
delta_state = parse_rrdp_state_tsv(delta_state_path)
|
||
|
||
base_meta = load_optional_json(base_meta_path_s)
|
||
download_stats = delta.get("download_stats") or {}
|
||
delta_meta = {
|
||
"recorded_at_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||
"tal_url": os.environ["TAL_URL"],
|
||
"base_db_dir": os.environ["BASE_DB_DIR"],
|
||
"delta_db_dir": os.environ["DELTA_DB_DIR"],
|
||
"base_report_json": str(base_report_path),
|
||
"delta_report_json": str(delta_report_path),
|
||
"delta_run_log": os.environ["DELTA_RUN_LOG"],
|
||
"validation_time_arg": os.environ.get("VALIDATION_TIME_ARG",""),
|
||
"http_timeout_secs": int(os.environ["HTTP_TIMEOUT_SECS"]),
|
||
"rsync_timeout_secs": int(os.environ["RSYNC_TIMEOUT_SECS"]),
|
||
"durations_secs": {
|
||
"rpki_run": int(os.environ["RUN_DURATION_S"]),
|
||
"db_stats_exact": int(os.environ["DB_STATS_DURATION_S"]),
|
||
"rrdp_state_dump": int(os.environ["STATE_DURATION_S"]),
|
||
"total_script": int(os.environ["TOTAL_DURATION_S"]),
|
||
},
|
||
"download_stats": download_stats,
|
||
}
|
||
delta_meta_path.write_text(json.dumps(delta_meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||
|
||
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
|
||
# RRDP state changes
|
||
serial_changed = 0
|
||
session_changed = 0
|
||
serial_deltas = []
|
||
for uri, (old_serial, old_sess) in base_state.items():
|
||
if uri not in delta_state:
|
||
continue
|
||
new_serial, new_sess = delta_state[uri]
|
||
if new_serial != old_serial:
|
||
serial_changed += 1
|
||
serial_deltas.append((uri, old_serial, new_serial, new_serial - old_serial))
|
||
if new_sess != old_sess:
|
||
session_changed += 1
|
||
|
||
serial_deltas.sort(key=lambda x: x[3], reverse=True)
|
||
|
||
# Publication point diffs
|
||
base_pp = {pp["manifest_rsync_uri"]: pp for pp in base["publication_points"]}
|
||
delta_pp = {pp["manifest_rsync_uri"]: pp for pp in delta["publication_points"]}
|
||
|
||
base_keys = set(base_pp.keys())
|
||
delta_keys = set(delta_pp.keys())
|
||
|
||
new_pp = sorted(delta_keys - base_keys)
|
||
missing_pp = sorted(base_keys - delta_keys)
|
||
|
||
updated_pp = 0
|
||
unchanged_pp = 0
|
||
for k in sorted(base_keys & delta_keys):
|
||
if pp_manifest_sha(base_pp[k]) != pp_manifest_sha(delta_pp[k]):
|
||
updated_pp += 1
|
||
else:
|
||
unchanged_pp += 1
|
||
|
||
# Cache usage + repo sync failure hints
|
||
def source_counts(rep: dict) -> Counter:
|
||
c = Counter()
|
||
for pp in rep["publication_points"]:
|
||
c[pp.get("source","")] += 1
|
||
return c
|
||
|
||
base_sources = source_counts(base)
|
||
delta_sources = source_counts(delta)
|
||
base_repo_sync_failed = count_repo_sync_failed(base)
|
||
delta_repo_sync_failed = count_repo_sync_failed(delta)
|
||
|
||
def cache_reason_counts(rep: dict) -> Counter:
|
||
c = Counter()
|
||
for pp in rep.get("publication_points", []):
|
||
if pp.get("source") != "vcir_current_instance":
|
||
continue
|
||
# Use warning messages as "reason". If missing, emit a fallback bucket.
|
||
ws = pp.get("warnings", [])
|
||
if not ws:
|
||
c["(no warnings recorded)"] += 1
|
||
continue
|
||
for w in ws:
|
||
msg = w.get("message", "").strip() or "(empty warning message)"
|
||
c[msg] += 1
|
||
return c
|
||
|
||
base_cache_reasons = cache_reason_counts(base)
|
||
delta_cache_reasons = cache_reason_counts(delta)
|
||
|
||
# Object change stats (by rsync URI, sha256)
|
||
base_obj = pp_objects_by_uri(base)
|
||
delta_obj = pp_objects_by_uri(delta)
|
||
|
||
kind_stats = {k: {"added": 0, "changed": 0, "removed": 0} for k in ["manifest","crl","certificate","roa","aspa","other"]}
|
||
all_uris = set(base_obj.keys()) | set(delta_obj.keys())
|
||
for uri in all_uris:
|
||
b = base_obj.get(uri)
|
||
d = delta_obj.get(uri)
|
||
if b is None and d is not None:
|
||
kind_stats[d[1]]["added"] += 1
|
||
elif b is not None and d is None:
|
||
kind_stats[b[1]]["removed"] += 1
|
||
else:
|
||
if b[0] != d[0]:
|
||
kind_stats[d[1]]["changed"] += 1
|
||
|
||
# VRP diff
|
||
base_v = vrp_set(base)
|
||
delta_v = vrp_set(delta)
|
||
added_v = delta_v - base_v
|
||
removed_v = base_v - delta_v
|
||
|
||
def fmt_db_stats(db: dict) -> str:
|
||
ordered = [
|
||
"mode",
|
||
"repository_view",
|
||
"raw_by_hash",
|
||
"vcir",
|
||
"audit_rule_index",
|
||
"rrdp_source",
|
||
"rrdp_source_member",
|
||
"rrdp_uri_owner",
|
||
"rrdp_state",
|
||
"raw_objects",
|
||
"rrdp_object_index",
|
||
"group_current_repository_view",
|
||
"group_current_validation_state",
|
||
"group_current_rrdp_state",
|
||
"group_legacy_compatibility",
|
||
"total",
|
||
"sst_files",
|
||
]
|
||
out = []
|
||
seen = set()
|
||
for k in ordered:
|
||
if k in db:
|
||
out.append(f"- `{k}={db[k]}`")
|
||
seen.add(k)
|
||
for k in sorted(set(db) - seen):
|
||
out.append(f"- `{k}={db[k]}`")
|
||
return "\n".join(out) if out else "_(missing db_stats keys)_"
|
||
|
||
lines = []
|
||
lines.append("# APNIC RRDP 增量同步验收(manual_sync)\n\n")
|
||
lines.append(f"时间戳:`{now}`(UTC)\n\n")
|
||
|
||
lines.append("## 复现信息\n\n")
|
||
lines.append(f"- base_report:`{base_report_path}`\n")
|
||
lines.append(f"- delta_report:`{delta_report_path}`\n")
|
||
lines.append(f"- base_db_stats:`{base_db_stats_path}`\n")
|
||
lines.append(f"- delta_db_stats:`{delta_db_stats_path}`\n")
|
||
lines.append(f"- base_rrdp_state:`{base_state_path}`\n")
|
||
lines.append(f"- delta_rrdp_state:`{delta_state_path}`\n\n")
|
||
|
||
lines.append("## 运行结果概览\n\n")
|
||
lines.append("| metric | base | delta |\n")
|
||
lines.append("|---|---:|---:|\n")
|
||
for k in [
|
||
"validation_time",
|
||
"publication_points_processed",
|
||
"publication_points_failed",
|
||
"rrdp_repos_unique",
|
||
"vrps",
|
||
"aspas",
|
||
"audit_publication_points",
|
||
"warnings_total",
|
||
]:
|
||
lines.append(f"| {k} | {base_sum[k]} | {delta_sum[k]} |\n")
|
||
lines.append("\n")
|
||
|
||
def dur(meta: dict | None, key: str):
|
||
if not meta:
|
||
return None
|
||
return (meta.get("durations_secs") or {}).get(key)
|
||
|
||
base_rpki_run = dur(base_meta, "rpki_run")
|
||
delta_rpki_run = delta_meta["durations_secs"]["rpki_run"]
|
||
base_total = dur(base_meta, "total_script")
|
||
delta_total = delta_meta["durations_secs"]["total_script"]
|
||
|
||
lines.append("## 持续时间(seconds)\n\n")
|
||
lines.append("| step | base | delta |\n")
|
||
lines.append("|---|---:|---:|\n")
|
||
lines.append(f"| rpki_run | {base_rpki_run if base_rpki_run is not None else 'unknown'} | {delta_rpki_run} |\n")
|
||
lines.append(f"| total_script | {base_total if base_total is not None else 'unknown'} | {delta_total} |\n")
|
||
lines.append("\n")
|
||
if base_meta is None and base_meta_path_s:
|
||
lines.append(f"> 注:未能读取 base meta:`{base_meta_path_s}`(文件不存在或不可读)。建议用 `full_sync.sh` 生成 baseline 以获得 base 时长对比。\n\n")
|
||
|
||
lines.append("RocksDB KV(`db_stats --exact`):\n\n")
|
||
lines.append("### 基线(base)\n\n")
|
||
lines.append(fmt_db_stats(base_db) + "\n\n")
|
||
lines.append("### 增量(delta)\n\n")
|
||
lines.append(fmt_db_stats(delta_db) + "\n\n")
|
||
|
||
lines.append("## RRDP 增量是否发生(基于 `rrdp_state` 变化)\n\n")
|
||
lines.append(f"- repo_total(base)={len(base_state)}\n")
|
||
lines.append(f"- repo_total(delta)={len(delta_state)}\n")
|
||
lines.append(f"- serial_changed={serial_changed}\n")
|
||
lines.append(f"- session_changed={session_changed}\n\n")
|
||
if serial_deltas:
|
||
lines.append("serial 增长最大的 10 个 RRDP repo(old -> new):\n\n")
|
||
for uri, old, new, diff in serial_deltas[:10]:
|
||
lines.append(f"- `{uri}`:`{old} -> {new}`(+{diff})\n")
|
||
lines.append("\n")
|
||
|
||
lines.append("## 发布点(Publication Point)变化统计\n\n")
|
||
lines.append("以 `manifest_rsync_uri` 作为发布点 key,对比 base vs delta:\n\n")
|
||
lines.append(f"- base PP:`{len(base_keys)}`\n")
|
||
lines.append(f"- delta PP:`{len(delta_keys)}`\n")
|
||
lines.append(f"- `new_pp={len(new_pp)}`\n")
|
||
lines.append(f"- `missing_pp={len(missing_pp)}`\n")
|
||
lines.append(f"- `updated_pp={updated_pp}`\n")
|
||
lines.append(f"- `unchanged_pp={unchanged_pp}`\n\n")
|
||
lines.append("> 注:`new_pp/missing_pp/updated_pp` 会混入“遍历范围变化”的影响(例如 validation_time 不同、或 base 中存在更多失败 PP)。\n\n")
|
||
|
||
lines.append("## fail fetch / VCIR 当前实例缓存复用情况\n\n")
|
||
lines.append(f"- repo sync failed(启发式:warning contains 'repo sync failed'/'rrdp fetch failed'/'rsync fetch failed')\n")
|
||
lines.append(f" - base:`{base_repo_sync_failed}`\n")
|
||
lines.append(f" - delta:`{delta_repo_sync_failed}`\n\n")
|
||
|
||
lines.append("- source 计数(按 `PublicationPointAudit.source`):\n\n")
|
||
lines.append(f" - base:`{dict(base_sources)}`\n")
|
||
lines.append(f" - delta:`{dict(delta_sources)}`\n\n")
|
||
|
||
def render_cache_reasons(title: str, c: Counter) -> str:
|
||
if not c:
|
||
return f"{title}:`0`(未使用 VCIR 当前实例缓存复用)\n\n"
|
||
lines = []
|
||
total = sum(c.values())
|
||
lines.append(f"{title}:`{total}`\n\n")
|
||
lines.append("Top reasons(按 warning message 聚合,可能一条 PP 有多条 warning):\n\n")
|
||
for msg, n in c.most_common(10):
|
||
lines.append(f"- `{n}` × {msg}\n")
|
||
lines.append("\n")
|
||
return "".join(lines)
|
||
|
||
lines.append(render_cache_reasons("- base `source=vcir_current_instance`", base_cache_reasons))
|
||
lines.append(render_cache_reasons("- delta `source=vcir_current_instance`", delta_cache_reasons))
|
||
|
||
lines.append("## 文件变更统计(按对象类型)\n\n")
|
||
lines.append("按 `ObjectAuditEntry.sha256_hex` 对比(同一 rsync URI 前后 hash 变化记为 `~changed`):\n\n")
|
||
lines.append("| kind | added | changed | removed |\n")
|
||
lines.append("|---|---:|---:|---:|\n")
|
||
for kind in ["manifest","crl","certificate","roa","aspa","other"]:
|
||
st = kind_stats[kind]
|
||
lines.append(f"| {kind} | {st['added']} | {st['changed']} | {st['removed']} |\n")
|
||
lines.append("\n")
|
||
|
||
lines.append("## VRP 影响(去重后集合 diff)\n\n")
|
||
lines.append("以 `(asn, prefix, max_length)` 为 key:\n\n")
|
||
lines.append(f"- base unique VRP:`{len(base_v)}`\n")
|
||
lines.append(f"- delta unique VRP:`{len(delta_v)}`\n")
|
||
lines.append(f"- `added={len(added_v)}`\n")
|
||
lines.append(f"- `removed={len(removed_v)}`\n")
|
||
lines.append(f"- `net={len(added_v) - len(removed_v)}`\n\n")
|
||
|
||
out_md_path.write_text("".join(lines), encoding="utf-8")
|
||
print(out_md_path)
|
||
PY
|
||
|
||
echo "== done ==" >&2
|
||
echo "artifacts:" >&2
|
||
echo "- delta db: $DELTA_DB_DIR" >&2
|
||
echo "- delta report: $DELTA_REPORT_JSON" >&2
|
||
echo "- delta run log: $DELTA_RUN_LOG" >&2
|
||
echo "- delta meta json: $DELTA_META_JSON" >&2
|
||
echo "- analysis md: $DELTA_ANALYSIS_MD" >&2
|
||
echo "- base state tsv: $BASE_RRDP_STATE_TSV" >&2
|
||
echo "- delta state tsv: $DELTA_RRDP_STATE_TSV" >&2
|