CUST-WP-0071-T05: hub delivery per weekly report and daily missed/failed-run check
The in-cluster runner posts one State Hub message (custodian-allocation-review -> the-custodian) per newly retained week, records the message id in receipts.jsonl, never reposts, and retries an undelivered week. New weekly_allocation_check.py flags a failed latest receipt or a missing report for the due ISO week (Monday 12:00 Europe/Berlin); security_flow_daily.py notifies once per distinct flag set and stays inactive until a receipts source is configured. Founder decision 2026-10-21 (T05 follow-up answers). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
4c89148bba
commit
9edd3151ac
7 changed files with 337 additions and 6 deletions
|
|
@ -6,12 +6,14 @@ Dry-run by default. --apply is the scheduled mode. No credentials are stored.
|
|||
import argparse
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from security_flow_report import DEFAULT_REPOS
|
||||
import weekly_allocation_check
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parent
|
||||
|
||||
|
|
@ -27,15 +29,26 @@ def notifications(report, state, agent, founder):
|
|||
return pending
|
||||
|
||||
|
||||
def post(hub, recipient, payload):
|
||||
def post(hub, recipient, payload, subject="Daily security flow"):
|
||||
body = {"from_agent": "the-custodian", "to_agent": recipient,
|
||||
"subject": "Daily security flow", "body": json.dumps(payload, sort_keys=True)}
|
||||
"subject": subject, "body": json.dumps(payload, sort_keys=True)}
|
||||
request = Request(hub.rstrip("/") + "/messages/", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urlopen(request, timeout=30) as response:
|
||||
return json.load(response)["id"]
|
||||
|
||||
|
||||
def allocation_notice(result, state):
|
||||
"""CUST-WP-0071-T05: notify once per distinct flag set; clear when healthy."""
|
||||
if result.get("status") != "flagged":
|
||||
if result.get("status") == "ok":
|
||||
state.pop("allocation", None)
|
||||
return None
|
||||
if result["flags"] == state.get("allocation"):
|
||||
return None
|
||||
return {"WEEKLY_ALLOCATION": result}
|
||||
|
||||
|
||||
def save(path, data):
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(data, indent=2) + "\n")
|
||||
|
|
@ -50,6 +63,8 @@ def main():
|
|||
parser.add_argument("--agent", default="the-custodian")
|
||||
parser.add_argument("--founder", default="human")
|
||||
parser.add_argument("--state-dir", type=Path, default=Path.home() / ".local/state/custodian-security-flow")
|
||||
parser.add_argument("--allocation-receipts", default=os.environ.get("CUSTODIAN_ALLOCATION_RECEIPTS"),
|
||||
help="weekly allocation receipts.jsonl path or 'hub'; unset keeps the check inactive")
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -72,6 +87,20 @@ def main():
|
|||
state[key] = payload
|
||||
state[key + "_message_id"] = message_id
|
||||
save(state_path, state) # A later recipient failure does not replay earlier sends.
|
||||
try:
|
||||
allocation = weekly_allocation_check.check(args.allocation_receipts, args.hub)
|
||||
except (OSError, ValueError) as exc: # never break the security flow
|
||||
allocation = {"status": "error", "error": type(exc).__name__}
|
||||
notice = allocation_notice(allocation, state)
|
||||
if notice and not args.apply:
|
||||
print(json.dumps({"dry_run": True, "to_agent": args.agent, "report": notice}))
|
||||
elif notice:
|
||||
state["allocation_message_id"] = post(args.hub, args.agent, notice, "Weekly allocation assessment check")
|
||||
state["allocation"] = allocation["flags"]
|
||||
if args.apply:
|
||||
save(state_path, state)
|
||||
if allocation["status"] != "inactive":
|
||||
print(json.dumps({"weekly_allocation": allocation}, sort_keys=True))
|
||||
print(raw)
|
||||
|
||||
|
||||
|
|
|
|||
99
scripts/weekly_allocation_check.py
Normal file
99
scripts/weekly_allocation_check.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env python3
|
||||
"""CUST-WP-0071-T05 missed/failed-run check for the weekly allocation assessment.
|
||||
|
||||
Flags `failed` when the latest receipt failed, and `missing` when no retained
|
||||
report exists for the due ISO week. The current week falls due on Monday 12:00
|
||||
Europe/Berlin; before then the previous week is checked. Week labels follow
|
||||
weekly_allocation_incluster.week_of (ISO week of UTC time minus 6h).
|
||||
|
||||
Receipts source (inactive when unset, so harmless before the CronJob runs):
|
||||
- a path to a copy of the job's receipts.jsonl (failed and missing), or
|
||||
- `hub`: the job's delivery messages (from custodian-allocation-review to
|
||||
the-custodian). Only retained weeks are posted, so a failed run shows as missing.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import urlopen
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
BERLIN = ZoneInfo("Europe/Berlin")
|
||||
OK = {"retained", "already-retained"}
|
||||
WINDOW_END_HOUR = 6
|
||||
|
||||
|
||||
def week_label(moment: datetime) -> str:
|
||||
year, week, _ = (moment.astimezone(timezone.utc) - timedelta(hours=WINDOW_END_HOUR)).isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
|
||||
|
||||
def due_week(now: datetime) -> str:
|
||||
local = now.astimezone(BERLIN)
|
||||
monday = (local - timedelta(days=local.weekday())).replace(hour=12, minute=0, second=0, microsecond=0)
|
||||
return week_label(monday if local >= monday else monday - timedelta(days=7))
|
||||
|
||||
|
||||
def evaluate(receipts: list[dict], now: datetime) -> dict:
|
||||
week = due_week(now)
|
||||
ordered = sorted(receipts, key=lambda r: r.get("run_at") or "")
|
||||
flags = []
|
||||
if ordered and ordered[-1].get("status") == "failed":
|
||||
flags.append({"flag": "failed", "week": ordered[-1].get("week"), "run_at": ordered[-1].get("run_at"),
|
||||
"error": ordered[-1].get("error")})
|
||||
if not any(r.get("week") == week and r.get("status") in OK for r in receipts):
|
||||
flags.append({"flag": "missing", "week": week,
|
||||
"detail": "no retained weekly allocation report for the due ISO week"})
|
||||
return {"status": "flagged" if flags else "ok", "due_week": week, "flags": flags,
|
||||
"recovery": "inspect CronJob custodian-assessment/custodian-allocation-review; rerun with "
|
||||
"kubectl create job --from=cronjob/custodian-allocation-review (same-week rerun is idempotent)"
|
||||
if flags else None}
|
||||
|
||||
|
||||
def from_file(path: Path) -> list[dict]:
|
||||
out = []
|
||||
for line in path.read_text().splitlines():
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def from_hub(hub: str, opener=urlopen) -> list[dict]:
|
||||
query = urlencode({"to_agent": "the-custodian", "from_agent": "custodian-allocation-review", "limit": 50})
|
||||
with opener(hub.rstrip("/") + "/messages/?" + query, timeout=30) as response:
|
||||
messages = json.load(response)
|
||||
out = []
|
||||
for message in messages:
|
||||
try:
|
||||
body = json.loads(message.get("body") or "{}")
|
||||
except ValueError:
|
||||
continue
|
||||
if body.get("week"):
|
||||
out.append({"week": body["week"], "status": "retained", "run_at": message.get("created_at")})
|
||||
return out
|
||||
|
||||
|
||||
def check(source: str | None, hub: str, now: datetime | None = None, opener=urlopen) -> dict:
|
||||
if not source:
|
||||
return {"status": "inactive", "reason": "no weekly allocation receipts source configured"}
|
||||
receipts = from_hub(hub, opener) if source == "hub" else from_file(Path(source))
|
||||
return evaluate(receipts, now or datetime.now(timezone.utc))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--receipts", help="receipts.jsonl path or 'hub'")
|
||||
parser.add_argument("--hub", default="http://127.0.0.1:8000")
|
||||
args = parser.parse_args()
|
||||
result = check(args.receipts, args.hub)
|
||||
print(json.dumps(result, indent=2))
|
||||
return 1 if result["status"] == "flagged" else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -15,6 +15,12 @@ the Monday 08:00 Europe/Berlin schedule in both CET and CEST and keeps the
|
|||
window start as close as possible to the seven-day Prometheus retention edge. A week whose report is already
|
||||
retained is a no-op success (idempotent retry); a failed attempt leaves no
|
||||
report, so the Job's retry or the next manual run collects again.
|
||||
|
||||
Delivery (founder decision 2026-10-21, "Hub message + repo tasks"): after a
|
||||
week is retained, one State Hub message goes from custodian-allocation-review
|
||||
to the-custodian with a short summary, report path and sha256. The message id
|
||||
is recorded in receipts.jsonl; a week that already has a message id is never
|
||||
posted again, and a retained-but-undelivered week is posted by the next run.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -170,6 +176,61 @@ def append_receipt(data: Path, receipt: dict) -> None:
|
|||
print(json.dumps(receipt, sort_keys=True))
|
||||
|
||||
|
||||
def delivered_message(data: Path, week: str) -> str | None:
|
||||
path = data / "receipts.jsonl"
|
||||
if not path.exists():
|
||||
return None
|
||||
for line in reversed(path.read_text().splitlines()):
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if entry.get("week") == week and entry.get("message_id"):
|
||||
return entry["message_id"]
|
||||
return None
|
||||
|
||||
|
||||
def summary(receipt: dict) -> dict:
|
||||
report = json.loads(Path(receipt["report"]).read_text())
|
||||
decisions: dict[str, int] = {}
|
||||
for item in report["recommendations"]:
|
||||
decisions[item["decision"]] = decisions.get(item["decision"], 0) + 1
|
||||
return {"week": receipt["week"], "report": receipt["report"], "report_sha256": receipt["report_sha256"],
|
||||
"capture_end": report["period"]["capture_end"], "workloads": len(report["workloads"]),
|
||||
"namespaces_with_metrics": len(report["namespace_context"]),
|
||||
"recommendations": dict(sorted(decisions.items())),
|
||||
"comparison_compatible": report["comparison"]["compatible"],
|
||||
"measurement_gaps": len(report["measurement_gaps"]),
|
||||
"failed_queries": sorted(n for n, q in report["queries"].items() if q.get("response_status") != "success"),
|
||||
"next_step": "Custodian session reviews and records owner-repo tasks (ADR-001); no automatic resizing."}
|
||||
|
||||
|
||||
def hub_post(hub: str, body: dict) -> str:
|
||||
request = urllib.request.Request(hub.rstrip("/") + "/messages/", data=json.dumps(body).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
return str(json.load(response)["id"])
|
||||
|
||||
|
||||
def deliver(data: Path, receipt: dict, hub: str | None, post=hub_post) -> tuple[dict, bool]:
|
||||
"""Post one hub message per retained week; returns (receipt, ok)."""
|
||||
if receipt.get("status") not in {"retained", "already-retained"}:
|
||||
return receipt, True
|
||||
existing = delivered_message(data, receipt["week"])
|
||||
if existing:
|
||||
return {**receipt, "delivery": "already-posted", "message_id": existing}, True
|
||||
if not hub:
|
||||
return {**receipt, "delivery": "not configured"}, True
|
||||
content = summary(receipt)
|
||||
body = {"from_agent": "custodian-allocation-review", "to_agent": "the-custodian",
|
||||
"subject": f"Weekly allocation assessment {receipt['week']}",
|
||||
"body": json.dumps(content, sort_keys=True)}
|
||||
try:
|
||||
return {**receipt, "delivery": "posted", "message_id": post(hub, body)}, True
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
return {**receipt, "delivery": f"failed: {type(exc).__name__}"}, False
|
||||
|
||||
|
||||
def run(args, *, now: datetime, get, query) -> dict:
|
||||
sources = {
|
||||
"weekly_allocation_report": args.source_dir / "weekly_allocation_report.py",
|
||||
|
|
@ -242,6 +303,7 @@ def parser() -> argparse.ArgumentParser:
|
|||
p.add_argument("--inventory", type=Path, default=env("ASSESSMENT_INVENTORY") and Path(env("ASSESSMENT_INVENTORY")))
|
||||
p.add_argument("--reviews", type=Path, default=env("ASSESSMENT_REVIEWS") and Path(env("ASSESSMENT_REVIEWS")))
|
||||
p.add_argument("--prometheus-url", default=env("PROMETHEUS_URL", "http://telemetry-prometheus.telemetry.svc:9090"))
|
||||
p.add_argument("--hub-url", default=env("STATE_HUB_URL"), help="State Hub API; unset disables delivery")
|
||||
p.add_argument("--executor", default=env("ASSESSMENT_EXECUTOR", "unspecified"))
|
||||
p.add_argument("--max-age-hours", type=float, default=float(env("ASSESSMENT_MAX_AGE_HOURS", "48")))
|
||||
return p
|
||||
|
|
@ -264,8 +326,9 @@ def main() -> int:
|
|||
"week": label, "run_at": iso(now), "status": "failed",
|
||||
"error": f"{type(exc).__name__}: {exc}"[:500]})
|
||||
return 1
|
||||
receipt, delivered = deliver(args.data_dir, receipt, args.hub_url)
|
||||
append_receipt(args.data_dir, receipt)
|
||||
return 0
|
||||
return 0 if delivered else 1 # undelivered: Job retry posts it (report already retained)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue