a3bb78e06b
Security Quality Gate / npm audit, Bandit, pip-audit, detect-secrets (pull_request) Failing after 16m35s
268 lines
10 KiB
Python
268 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Watch deprecated API alias usage over a short observation window."""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
import urllib.parse
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Set, Tuple
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "project-docs" / "qa-artifacts" / "v2-deprecated-alias-observation"
|
|
|
|
|
|
def checked_at() -> str:
|
|
return datetime.now(timezone.utc).astimezone().isoformat()
|
|
|
|
|
|
def now_stamp() -> str:
|
|
return datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
|
|
|
|
def normalize_base_url(value: str) -> str:
|
|
return value.rstrip("/")
|
|
|
|
|
|
def build_metrics_url(base_url: str, api_prefix: str, alias_group: str, page_size: int) -> str:
|
|
query = urllib.parse.urlencode({"alias_group": alias_group, "page_size": page_size})
|
|
return f"{normalize_base_url(base_url)}{api_prefix.rstrip('/')}/metrics/deprecated-aliases?{query}"
|
|
|
|
|
|
def fetch_json(url: str, timeout_seconds: float) -> Dict[str, Any]:
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"Accept": "application/json",
|
|
"User-Agent": "yuqei-v2-deprecated-alias-watch/1.0",
|
|
},
|
|
method="GET",
|
|
)
|
|
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
|
payload = response.read().decode("utf-8")
|
|
data = json.loads(payload)
|
|
if not isinstance(data, dict):
|
|
raise ValueError("deprecated alias metrics response must be a JSON object")
|
|
return data
|
|
|
|
|
|
def usage_items(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
items = data.get("items", [])
|
|
if not isinstance(items, list):
|
|
raise ValueError("deprecated alias metrics response items must be a list")
|
|
return [item for item in items if isinstance(item, dict)]
|
|
|
|
|
|
def usage_total(data: Dict[str, Any]) -> int:
|
|
total = data.get("total", 0)
|
|
if isinstance(total, int):
|
|
return total
|
|
return int(total)
|
|
|
|
|
|
def filter_ignored_items(
|
|
items: List[Dict[str, Any]],
|
|
*,
|
|
ignored_trace_ids: Set[str],
|
|
ignored_trace_prefixes: Tuple[str, ...],
|
|
) -> List[Dict[str, Any]]:
|
|
filtered = [] # type: List[Dict[str, Any]]
|
|
for item in items:
|
|
trace_id = str(item.get("trace_id") or "")
|
|
if trace_id in ignored_trace_ids:
|
|
continue
|
|
if ignored_trace_prefixes and trace_id.startswith(ignored_trace_prefixes):
|
|
continue
|
|
filtered.append(item)
|
|
return filtered
|
|
|
|
|
|
def collect_new_items(
|
|
data: Dict[str, Any],
|
|
*,
|
|
baseline_ids: Set[str],
|
|
ignored_trace_ids: Set[str],
|
|
ignored_trace_prefixes: Tuple[str, ...],
|
|
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
|
items = usage_items(data)
|
|
new_items = [item for item in items if item.get("id") and str(item["id"]) not in baseline_ids]
|
|
relevant_new_items = filter_ignored_items(
|
|
new_items,
|
|
ignored_trace_ids=ignored_trace_ids,
|
|
ignored_trace_prefixes=ignored_trace_prefixes,
|
|
)
|
|
return new_items, relevant_new_items
|
|
|
|
|
|
def redact_item(item: Dict[str, Any]) -> Dict[str, Any]:
|
|
allowed_keys = {
|
|
"id",
|
|
"alias_group",
|
|
"fields",
|
|
"target_field",
|
|
"compatibility_status",
|
|
"contract_id",
|
|
"actor_name_present",
|
|
"endpoint",
|
|
"method",
|
|
"trace_id",
|
|
"tenant_id",
|
|
"timestamp",
|
|
}
|
|
return {key: item.get(key) for key in allowed_keys if key in item}
|
|
|
|
|
|
def write_artifact(artifact_dir: Path, report: Dict[str, Any]) -> Path:
|
|
artifact_dir.mkdir(parents=True, exist_ok=True)
|
|
path = artifact_dir / f"deprecated-alias-observation-{now_stamp()}.json"
|
|
path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def print_progress(message: str, *, quiet: bool) -> None:
|
|
if not quiet:
|
|
print(message, flush=True)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Watch deprecated V2 API alias usage.")
|
|
parser.add_argument("--base-url", default="http://localhost:8100", help="Contract API base URL.")
|
|
parser.add_argument("--api-prefix", default="/api/v1", help="Contract API prefix.")
|
|
parser.add_argument("--alias-group", default="contract_update_action", help="Deprecated alias group to observe.")
|
|
parser.add_argument("--duration-seconds", type=float, default=300.0, help="Observation window length.")
|
|
parser.add_argument("--interval-seconds", type=float, default=30.0, help="Polling interval.")
|
|
parser.add_argument("--timeout-seconds", type=float, default=20.0, help="HTTP timeout per poll.")
|
|
parser.add_argument("--page-size", type=int, default=100, help="Deprecated alias metric page size.")
|
|
parser.add_argument("--max-allowed-new", type=int, default=0, help="Maximum allowed new usages during window.")
|
|
parser.add_argument("--ignore-trace-id", action="append", default=[], help="Trace id to ignore.")
|
|
parser.add_argument("--ignore-trace-prefix", action="append", default=[], help="Trace id prefix to ignore.")
|
|
parser.add_argument("--artifact-dir", default=str(DEFAULT_ARTIFACT_ROOT), help="Directory for JSON report.")
|
|
parser.add_argument("--no-artifact", action="store_true", help="Do not write a JSON report.")
|
|
parser.add_argument("--quiet", action="store_true", help="Only print the final report.")
|
|
args = parser.parse_args()
|
|
|
|
duration_seconds = max(args.duration_seconds, 0.0)
|
|
interval_seconds = max(args.interval_seconds, 0.1)
|
|
page_size = max(args.page_size, 1)
|
|
max_allowed_new = max(args.max_allowed_new, 0)
|
|
ignored_trace_ids = {str(value) for value in args.ignore_trace_id}
|
|
ignored_trace_prefixes = tuple(str(value) for value in args.ignore_trace_prefix)
|
|
|
|
url = build_metrics_url(args.base_url, args.api_prefix, args.alias_group, page_size)
|
|
started_at = checked_at()
|
|
started = time.monotonic()
|
|
|
|
print_progress(
|
|
"DEPRECATED_ALIAS_OBSERVATION_CONNECT "
|
|
f"alias_group={args.alias_group} duration_seconds={duration_seconds:g} "
|
|
f"interval_seconds={interval_seconds:g} url={url}",
|
|
quiet=args.quiet,
|
|
)
|
|
|
|
try:
|
|
baseline = fetch_json(url, args.timeout_seconds)
|
|
except Exception as exc:
|
|
print(f"DEPRECATED_ALIAS_OBSERVATION_ERROR stage=baseline url={url} error={exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
baseline_total = usage_total(baseline)
|
|
baseline_ids = {str(item.get("id")) for item in usage_items(baseline) if item.get("id")}
|
|
final = baseline
|
|
samples = 1
|
|
|
|
print_progress(
|
|
"DEPRECATED_ALIAS_OBSERVATION_START "
|
|
f"alias_group={args.alias_group} baseline_total={baseline_total} "
|
|
f"baseline_ids={len(baseline_ids)} max_allowed_new={max_allowed_new}",
|
|
quiet=args.quiet,
|
|
)
|
|
|
|
while True:
|
|
elapsed = time.monotonic() - started
|
|
remaining = duration_seconds - elapsed
|
|
if remaining <= 0:
|
|
break
|
|
time.sleep(min(interval_seconds, remaining))
|
|
try:
|
|
final = fetch_json(url, args.timeout_seconds)
|
|
except Exception as exc:
|
|
print(f"DEPRECATED_ALIAS_OBSERVATION_ERROR stage=poll url={url} error={exc}", file=sys.stderr)
|
|
return 2
|
|
samples += 1
|
|
final_total_for_progress = usage_total(final)
|
|
progress_new_items, progress_relevant_items = collect_new_items(
|
|
final,
|
|
baseline_ids=baseline_ids,
|
|
ignored_trace_ids=ignored_trace_ids,
|
|
ignored_trace_prefixes=ignored_trace_prefixes,
|
|
)
|
|
elapsed_after_poll = time.monotonic() - started
|
|
remaining_after_poll = max(duration_seconds - elapsed_after_poll, 0.0)
|
|
observed_delta_for_progress = max(final_total_for_progress - baseline_total, len(progress_new_items))
|
|
print_progress(
|
|
"DEPRECATED_ALIAS_OBSERVATION_POLL "
|
|
f"sample={samples} elapsed_seconds={elapsed_after_poll:.1f} "
|
|
f"remaining_seconds={remaining_after_poll:.1f} baseline_total={baseline_total} "
|
|
f"final_total={final_total_for_progress} observed_delta={observed_delta_for_progress} "
|
|
f"relevant_new_count={len(progress_relevant_items)} max_allowed_new={max_allowed_new}",
|
|
quiet=args.quiet,
|
|
)
|
|
|
|
final_total = usage_total(final)
|
|
new_items, relevant_new_items = collect_new_items(
|
|
final,
|
|
baseline_ids=baseline_ids,
|
|
ignored_trace_ids=ignored_trace_ids,
|
|
ignored_trace_prefixes=ignored_trace_prefixes,
|
|
)
|
|
reset_detected = final_total < baseline_total
|
|
observed_delta = max(final_total - baseline_total, len(new_items))
|
|
relevant_new_count = len(relevant_new_items)
|
|
passed = relevant_new_count <= max_allowed_new
|
|
|
|
report = {
|
|
"status": "pass" if passed else "fail",
|
|
"checked_at": checked_at(),
|
|
"started_at": started_at,
|
|
"duration_seconds": round(time.monotonic() - started, 3),
|
|
"samples": samples,
|
|
"url": url,
|
|
"alias_group": args.alias_group,
|
|
"baseline_total": baseline_total,
|
|
"final_total": final_total,
|
|
"observed_delta": observed_delta,
|
|
"relevant_new_count": relevant_new_count,
|
|
"max_allowed_new": max_allowed_new,
|
|
"reset_detected": reset_detected,
|
|
"ignored_trace_ids": sorted(ignored_trace_ids),
|
|
"ignored_trace_prefixes": list(ignored_trace_prefixes),
|
|
"new_items": [redact_item(item) for item in relevant_new_items],
|
|
}
|
|
if not args.no_artifact:
|
|
report["artifact_path"] = str(write_artifact(Path(args.artifact_dir), report))
|
|
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
if passed:
|
|
print(
|
|
"DEPRECATED_ALIAS_OBSERVATION_PASS "
|
|
f"alias_group={args.alias_group} relevant_new_count={relevant_new_count} "
|
|
f"baseline_total={baseline_total} final_total={final_total}"
|
|
)
|
|
return 0
|
|
|
|
print(
|
|
"DEPRECATED_ALIAS_OBSERVATION_FAIL "
|
|
f"alias_group={args.alias_group} relevant_new_count={relevant_new_count} "
|
|
f"max_allowed_new={max_allowed_new}",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|