#!/usr/bin/env python3 """Apply jira_tcoc_fliter_for_week.json manual-not-incident updates to prod.""" from __future__ import annotations import argparse import json import re import subprocess import sys from pathlib import Path from typing import Any PROD_CONTEXT = "arn:aws:eks:us-west-2:557537366020:cluster/devops-prod" DEV_CONTEXT = "arn:aws:eks:us-west-2:552185537300:cluster/devops-staging" NAMESPACE = "investibook" DB_PATH = "/data/db/investibook.db" FILTER_FILENAME = "jira_tcoc_st_tcid_fliter_for_week.json" ACTOR = "investibook_bot" def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=f"Apply {FILTER_FILENAME} manual-not-incident updates to prod.", ) parser.add_argument( "date", help="Export package date, for example 2026-06-01. Reads exports//.", ) parser.add_argument( "--exports-root", default="exports", help="Root directory containing date-named export packages. Default: exports", ) parser.add_argument( "--apply", action="store_true", help="Actually write to the prod SQLite DB. Without this flag, only dry-run.", ) parser.add_argument( "--pod", help="Optional prod backend pod name. If omitted, discover the running ready pod.", ) return parser.parse_args() def normalize_date(value: str) -> str: match = re.fullmatch(r"\s*(\d{4})-(\d{1,2})-(\d{1,2})\s*", value) if not match: raise ValueError("date must be YYYY-MM-DD, for example 2026-06-01") year, month, day = (int(part) for part in match.groups()) return f"{year:04d}-{month:02d}-{day:02d}" def run_command(args: list[str]) -> str: proc = subprocess.run(args, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False) if proc.returncode != 0: detail = proc.stderr.strip() or proc.stdout.strip() raise RuntimeError("command failed: " + " ".join(args[:8]) + ("\n" + detail if detail else "")) return proc.stdout def kubectl_base() -> list[str]: if PROD_CONTEXT == DEV_CONTEXT or "devops-prod" not in PROD_CONTEXT: raise RuntimeError("refusing to run: prod context guard failed") return ["kubectl", "--context", PROD_CONTEXT, "-n", NAMESPACE] def find_backend_pod() -> str: output = run_command(kubectl_base() + ["get", "pods", "-l", "app=backend", "-o", "json"]) payload = json.loads(output) candidates: list[str] = [] for item in payload.get("items", []): if item.get("status", {}).get("phase") != "Running": continue conditions = item.get("status", {}).get("conditions", []) ready = any(cond.get("type") == "Ready" and cond.get("status") == "True" for cond in conditions) name = item.get("metadata", {}).get("name") if ready and name: candidates.append(name) if not candidates: raise RuntimeError("no running and ready prod backend pod found") return sorted(candidates)[0] def kubectl_exec_python(pod: str, code: str) -> str: return run_command(kubectl_base() + ["exec", pod, "--", "python3", "-c", code]) def read_json_array(path: Path) -> list[dict[str, Any]]: if not path.exists(): raise FileNotFoundError(str(path)) data = json.loads(path.read_text(encoding="utf-8")) if not isinstance(data, list): raise ValueError(f"{path} must contain a JSON array") for index, item in enumerate(data): if not isinstance(item, dict): raise ValueError(f"{path} item {index} must be an object") return data def unique_nonempty(values: list[Any]) -> list[str]: seen: set[str] = set() out: list[str] = [] for value in values: text = str(value or "").strip() if text and text not in seen: seen.add(text) out.append(text) return out def load_issue_keys(input_dir: Path) -> list[str]: rows = read_json_array(input_dir / FILTER_FILENAME) keys = unique_nonempty([row.get("issue_key") for row in rows]) if not keys: raise ValueError(f"{input_dir / FILTER_FILENAME} contains no issue_key values") return keys def remote_update(pod: str, issue_keys: list[str], apply: bool) -> dict[str, Any]: payload = { "db_path": DB_PATH, "actor": ACTOR, "apply": apply, "issue_keys": issue_keys, } code = f""" import json import sqlite3 payload = {json.dumps(payload, ensure_ascii=False)!r} req = json.loads(payload) db_path = req["db_path"] actor = req["actor"] apply = bool(req["apply"]) issue_keys = req["issue_keys"] conn = sqlite3.connect(db_path if apply else "file:" + db_path + "?mode=ro", uri=not apply) conn.row_factory = sqlite3.Row manual_rows = [] for issue_key in issue_keys: rows = conn.execute( ''' SELECT i.issue_key, i.dossier_id, COALESCE(d.manual_not_incident, 0) AS manual_not_incident, d.manual_not_incident_by FROM incident_dossier_issues i JOIN incident_dossiers d ON d.id = i.dossier_id WHERE i.issue_key = ? ORDER BY i.dossier_id ''', (issue_key,), ).fetchall() if not rows: manual_rows.append({{"issue_key": issue_key, "dossier_id": None, "status": "missing_issue"}}) continue first = rows[0] manual_rows.append({{ "issue_key": issue_key, "dossier_id": first["dossier_id"], "status": "matched", "already_marked": int(first["manual_not_incident"] or 0) == 1, "manual_not_incident_by": first["manual_not_incident_by"], "extra_dossier_count": max(0, len(rows) - 1), }}) updated = [] if apply: conn.execute("BEGIN") update_sql = ''' UPDATE incident_dossiers SET manual_not_incident = 1, manual_not_incident_at = strftime('%s', 'now'), manual_not_incident_by = ?, updated_at = strftime('%s', 'now') WHERE id = ( SELECT dossier_id FROM incident_dossier_issues WHERE issue_key = ? LIMIT 1 ) ''' for issue_key in issue_keys: cur = conn.execute(update_sql, (actor, issue_key)) updated.append({{"issue_key": issue_key, "rowcount": cur.rowcount}}) conn.commit() result = {{ "apply": apply, "input_count": len(issue_keys), "matched_count": sum(1 for row in manual_rows if row["status"] == "matched"), "missing_count": sum(1 for row in manual_rows if row["status"] == "missing_issue"), "already_marked_count": sum(1 for row in manual_rows if row.get("already_marked")), "rows": manual_rows, "updates": updated, "updated_rowcount": sum(row["rowcount"] for row in updated), }} print(json.dumps(result, ensure_ascii=False, indent=2)) """ output = kubectl_exec_python(pod, code) return json.loads(output) def main() -> int: args = parse_args() try: selected_date = normalize_date(args.date) input_dir = Path(args.exports_root) / selected_date if not input_dir.exists(): raise FileNotFoundError(str(input_dir)) issue_keys = load_issue_keys(input_dir) pod = args.pod or find_backend_pod() result = remote_update(pod, issue_keys, args.apply) report = { "date": selected_date, "input_file": str((input_dir / FILTER_FILENAME).resolve()), "mode": "apply" if args.apply else "dry-run", "prod_context": PROD_CONTEXT, "namespace": NAMESPACE, "backend_pod": pod, "result": result, } print(json.dumps(report, ensure_ascii=False, indent=2)) if not args.apply: print("\nDRY-RUN only. Re-run with --apply to write these prod updates.", file=sys.stderr) return 0 except Exception as exc: print(f"ERROR: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())