Merge pull request 'feat(daily-digest): deliver via Zulip DM as an HTML attachment; drop mail entirely' (#137) from fix/daily-digest-zulip-delivery-20260926 into master
PR Pipeline — Authorize → Validate → Review → Merge / auth (push) Successful in 9s
PR Pipeline — Authorize → Validate → Review → Merge / validate (push) Successful in 6s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (push) Successful in 2s
PR Pipeline — Authorize → Validate → Review → Merge / lint (push) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / gate (push) Successful in 1s

This commit was merged in pull request #137.
This commit is contained in:
2026-09-26 16:06:35 +00:00
3 changed files with 231 additions and 80 deletions
+14 -10
View File
@@ -1965,17 +1965,21 @@ contracts:
verify_commands: verify_commands:
- infisical run --env=prod -- python3 scripts/daily-infra-report.py --test-email - infisical run --env=prod -- python3 scripts/daily-infra-report.py --test-email
- python3 -m pytest tests/test_daily_infra_report.py -q - python3 -m pytest tests/test_daily_infra_report.py -q
email_dependency: delivery:
transport: smtp.gmail.com:587 transport: zulip-dm-attachment
identity: jtabiri@gmail.com recipient_user_id: 9
secret: EMAIL_PASSWORD (must be a Google app password) sender: abiba-bot@chat.sysloggh.net
status: DEGRADED as of 2026-09-25 - 534 5.7.9 Application-specific password required key_source: abiba-bot Zulip key already on the execution host, read from the
note: A delivery failure is a credential dependency, not a code defect. Tracked 600-mode env file /root/.pi/agent/extensions/zulip/.env
as daily-digest-mail-transport-20260921. key_policy: do NOT add a vault entry - that is a captain decision under the auth-keys charter
body: short Markdown pointer; the HTML attachment IS the report
artifact: /var/log/daily-infra-report/infra-report-<UTCstamp>.html
note: Replaced SMTP/mail on 2026-09-26 by captain decision. Removes the Google
dependency entirely; closes daily-digest-mail-transport-20260921.
exit_semantics: exit_semantics:
'1': missing PVE_TOKEN, unreachable Proxmox probe, or failed email send - raises an alert '1': missing PVE_TOKEN, unreachable Proxmox probe, missing/rejected Zulip
'0': healthy, or a deliberate DEGRADED leg where the email credential is absent credential, or a failed upload/post - raises an alert
and the report is still produced '0': healthy delivery only - there is no degraded delivery leg any more
depends_on: [] depends_on: []
last_run: null last_run: null
last_status: null last_status: null
+48 -32
View File
@@ -16,11 +16,12 @@ description: >
Exit-code semantics (as they actually behave, verified 2026-09-25): Exit-code semantics (as they actually behave, verified 2026-09-25):
* missing PVE_TOKEN, or an unreachable Proxmox probe -> exit 1 + alert * missing PVE_TOKEN, or an unreachable Proxmox probe -> exit 1 + alert
* missing EMAIL credential -> deliberate DEGRADED leg, exit 0, report still * missing or rejected Zulip credential -> exit 1 (delivery is the only
produced output path, so it is a real failure, not a degraded leg)
* email send failure -> exit 1 (a delivery fault, not a code defect) * delivery failure -> exit 1, and the report body is printed AND persisted
so the content is never swallowed
version: 1.0.0 version: 2.0.0
--- ---
## Purpose ## Purpose
@@ -93,14 +94,13 @@ $ infisical run --env=prod -- python3 scripts/daily-infra-report.py --json
EXIT=0 EXIT=0
``` ```
and in mail mode: and in delivery mode:
``` ```
Sending email... report ready: 16208 chars of HTML (delivered as a file attachment)
✅ All legs fully credentialed Sending to the captain's Zulip DM...
📋 Summary: ✅ Delivered to Zulip DM (user 9), message id 86221, attachment 16208 bytes
Proxmox: 5/5 nodes online at /user_uploads/2/45/m1cQesBFV78BGeNY2lN8xkN5/infra-report-20260926-153406.html
VMs/CTs: 22/22 running
``` ```
Healthy means: every probe reports `ok`, `nodes_online == node_count`, and the Healthy means: every probe reports `ok`, `nodes_online == node_count`, and the
@@ -115,9 +115,8 @@ Verified on 2026-09-25 by running each case deliberately.
| all probes reachable, email sent | 0 | — | healthy | | all probes reachable, email sent | 0 | — | healthy |
| **missing `PVE_TOKEN`** | **1** | yes | `PROBE FAILURES: proxmox: node list unreachable (PVE_TOKEN missing or API down)`, and `cluster resources unreachable` | | **missing `PVE_TOKEN`** | **1** | yes | `PROBE FAILURES: proxmox: node list unreachable (PVE_TOKEN missing or API down)`, and `cluster resources unreachable` |
| **Proxmox probe unreachable** | **1** | yes | same path as above; `pve_probe_status: unreachable` | | **Proxmox probe unreachable** | **1** | yes | same path as above; `pve_probe_status: unreachable` |
| **missing `EMAIL_PASSWORD`** | **0** | no | deliberate **DEGRADED** leg (`credential-missing: EMAIL_PASSWORD`); the report is still produced | | **missing/rejected Zulip credential** | **1** | yes | delivery is the only output path; report printed and persisted |
| **email send fails** | **1** | yes | e.g. Gmail `534 5.7.9 Application-specific password required` | | **upload or message post fails** | **1** | yes | report printed and persisted; message names which step failed |
| degraded legs present (non-email) | 0 | no | logged under `⚠️ Degraded legs` |
The distinction is deliberate and must not be flattened: The distinction is deliberate and must not be flattened:
@@ -130,22 +129,31 @@ The distinction is deliberate and must not be flattened:
`PROBE_FAILURES` and `DEGRADED_LEGS` are separate lists for exactly this `PROBE_FAILURES` and `DEGRADED_LEGS` are separate lists for exactly this
reason. Do not merge them. reason. Do not merge them.
## Email-delivery dependency ## Delivery: Zulip DM carrying the report as an HTML ATTACHMENT
Delivery is a **credential dependency, not a code path**. The producer Captain's decision 2026-09-26, clarified the same day: the digest is delivered to
authenticates to `smtp.gmail.com:587` as `jtabiri@gmail.com` with his **Zulip DM (user id 9)** from `abiba-bot@chat.sysloggh.net`, as an **HTML
`EMAIL_PASSWORD` from the vault and sends to `jerome@sysloggh.com`. FILE** — an attachment, not HTML rendered in the message body and not a Markdown
translation of it.
* Since that Google account has two-step verification, `EMAIL_PASSWORD` must be * the styled dashboard is built exactly as before and written to
a Google **app password**, not the account password. `/var/log/daily-infra-report/infra-report-<UTCstamp>.html`;
* As of 2026-09-25 delivery is **failing** with * it is uploaded through `POST /api/v1/user_uploads`;
`534 5.7.9 Application-specific password required`; the fix is for the * the **message body stays short Markdown** — subject line, top-line status
captain to generate a fresh app password and place it in Infisical (nodes online, guests running, any degraded legs), and a link to the
(`infrastructure/production`) as `EMAIL_PASSWORD`. attachment. The attachment IS the report; the body does not reproduce it.
* **A delivery failure is not a code defect.** Investigation of a failed send
should start at the credential, not the script. Chasing it as a code bug This removes the Google dependency entirely: **no SMTP, no `EMAIL_PASSWORD`, no
wastes the effort; verify the credential path first with `--test-email`. app password, nothing to rotate.** `daily-digest-mail-transport-20260921` is
* Tracked separately as `daily-digest-mail-transport-20260921`. closed under this option.
The **10,000-character message cap does not apply** — it bounds message TEXT
only, and the report travels as a file. Do not shrink the report to fit it.
The credential is abiba-bot's Zulip key already on the execution host at
`/root/.pi/agent/extensions/zulip/.env` (`ABIBA_ZULIP_API_KEY`, mode 600,
root-readable). **Do not place a new credential in the vault** — under the
auth-keys charter that is a captain decision.
## What counts as a failure ## What counts as a failure
@@ -153,10 +161,18 @@ A run FAILS (exit 1) when the report cannot be trusted or delivered:
* any probe is unreachable, so a section would silently be empty; * any probe is unreachable, so a section would silently be empty;
* `PVE_TOKEN` is missing; * `PVE_TOKEN` is missing;
* the email send fails. * the Zulip credential is missing or rejected, or the upload/post fails.
A run is DEGRADED (exit 0, report still produced) when a non-load-bearing There is **no degraded delivery leg any more**. Delivery is the only output
credential is absent, currently only `EMAIL_PASSWORD`. path, so a missing credential is a failure rather than a survivable degradation —
the previous "missing `EMAIL_PASSWORD` still exits 0" rule is retired with the
mail transport.
**A delivery failure must never swallow the report.** On failure the script
prints the report body to stdout *and* leaves the HTML artifact on disk, so the
content is always recoverable from the run log. That closes the queued defect
where a failed send printed only the transport error and the report never
surfaced.
## Failure behaviour ## Failure behaviour
@@ -175,7 +191,7 @@ infisical run --env=prod -- python3 scripts/daily-infra-report.py --json \
| grep -E 'pve_probe_status|node_count|nodes_online' | grep -E 'pve_probe_status|node_count|nodes_online'
# delivery path # delivery path
infisical run --env=prod -- python3 scripts/daily-infra-report.py --test-email infisical run --env=prod -- python3 scripts/daily-infra-report.py --test-zulip
``` ```
Regression tests: `tests/test_daily_infra_report.py` (7 tests). Four of them Regression tests: `tests/test_daily_infra_report.py` (7 tests). Four of them
@@ -183,5 +199,5 @@ fail against the pre-fix script, which is what makes them bite.
## Maintains ## Maintains
- daily-infra-dashboard: { status: "degraded", reason: "email credential", last_check: timestamp } - daily-infra-dashboard: { status: "ok|undelivered", transport: zulip-dm-attachment, last_check: timestamp }
- pve-probe: { status: "ok|unreachable", last_check: timestamp } - pve-probe: { status: "ok|unreachable", last_check: timestamp }
+164 -33
View File
@@ -243,7 +243,7 @@ def collect():
("Pulse", "https://pulse.sysloggh.net"), ("Pulse", "https://pulse.sysloggh.net"),
("Proxmox", "https://192.168.68.12:8006"), ("Proxmox", "https://192.168.68.12:8006"),
("SearXNG", "http://192.168.68.7:8888"), ("SearXNG", "http://192.168.68.7:8888"),
("Firecrawl", "http://192.168.68.7:3002/health"), ("Firecrawl", "http://192.168.68.7:3002/"), # Firecrawl serves no /health - the root is its liveness endpoint
] ]
report["endpoints"] = [] report["endpoints"] = []
for name, url in endpoints: for name, url in endpoints:
@@ -389,6 +389,28 @@ def collect():
# ── HTML Dashboard ── # ── HTML Dashboard ──
def classify_endpoint(code):
"""Classify an endpoint probe per the fleet's probe policy.
Codified 2026-09-14 in the monitoring contracts: ANY HTTP status proves the
service answered, so the service is ALIVE - 200/301/302/401/403/404 alike.
Only a failed CONNECTION (000 / timeout / refused) is a failed probe. A 404
from a wrong path is not a service fault and must not render as one.
This replaces a string comparison that was wrong in both directions
(`ep["code"] >= "400"`): it rendered 301 as red, 404 as yellow, and a real
500 as yellow. 5xx is kept as its own "server error" signal rather than
being merged with 4xx.
"""
if not code or code == "000":
return "red", "no connection"
if code.startswith("5"):
return "yellow", "server error"
if code.startswith(("2", "3", "4")):
return "green", "alive"
return "yellow", f"unexpected {code}"
def build_html(r): def build_html(r):
issues = [] issues = []
@@ -624,7 +646,7 @@ Proxmox: {r.get('pve_probe_status', 'ok')} ({r['nodes_online']}/{r['node_count']
# ── Network Endpoints ── # ── Network Endpoints ──
html += '<div class="card"><h2>🌐 Network Endpoints</h2><table><tr><th>Service</th><th>Status</th></tr>' html += '<div class="card"><h2>🌐 Network Endpoints</h2><table><tr><th>Service</th><th>Status</th></tr>'
for ep in r["endpoints"]: for ep in r["endpoints"]:
color = "green" if ep["code"] in ("200","302","401") else ("yellow" if ep["code"] >= "400" else "red") color = classify_endpoint(ep["code"])[0]
html += f'<tr><td>{ep["name"]}</td><td class="{color}">HTTP {ep["code"]}</td></tr>' html += f'<tr><td>{ep["name"]}</td><td class="{color}">HTTP {ep["code"]}</td></tr>'
html += '</table></div>' html += '</table></div>'
@@ -688,42 +710,152 @@ Proxmox: {r.get('pve_probe_status', 'ok')} ({r['nodes_online']}/{r['node_count']
return html return html
# ── Send Email ── # ── Delivery: Zulip DM carrying the report as an HTML ATTACHMENT ──
#
# Captain's decision, clarified 2026-09-26: the report is sent as an HTML FILE,
# i.e. an attachment - NOT HTML rendered in the message body, and NOT a Markdown
# translation of it. So the styled dashboard is built exactly as before, uploaded
# through Zulip's file-upload API, and the message body stays short: subject,
# top-line status, and a pointer to the attachment.
#
# This removes the Google dependency entirely (no SMTP, no EMAIL_PASSWORD).
# The 10,000-character message cap does not apply: it bounds message TEXT only,
# and the report travels as a file.
def send_email(html_content, subject_prefix=""): ZULIP_SITE = "https://chat.sysloggh.net"
FROM = "abiba@sysloggh.com" ZULIP_BOT_EMAIL = "abiba-bot@chat.sysloggh.net"
TO = "jerome@sysloggh.com" CAPTAIN_USER_ID = 9
SUBJECT = f"{subject_prefix}{'🏗️ Infrastructure Report — ' + DATE_STR}" ZULIP_KEY_FILE = "/root/.pi/agent/extensions/zulip/.env"
REPORT_ARTIFACT_DIR = "/var/log/daily-infra-report"
msg = MIMEMultipart("alternative")
msg["From"] = FROM
msg["To"] = TO
msg["Subject"] = SUBJECT
msg.attach(MIMEText("Infrastructure report in HTML format — enable images to view.", "plain"))
msg.attach(MIMEText(html_content, "html"))
def zulip_key():
"""abiba-bot's Zulip key, from the env or the on-host 600 file."""
key = os.environ.get("ABIBA_ZULIP_API_KEY")
if key:
return key.strip()
try: try:
EMAIL_PASSWORD = os.environ.get("EMAIL_PASSWORD") or os.environ.get("SMTP_PASSWORD") or os.environ.get("MAIL_PASSWORD") with open(ZULIP_KEY_FILE) as fh:
if not EMAIL_PASSWORD: for line in fh:
print(" ⚠️ Degraded leg: credential-missing: EMAIL_PASSWORD (or SMTP_PASSWORD/MAIL_PASSWORD)", file=sys.stderr) if line.startswith("ABIBA_ZULIP_API_KEY="):
DEGRADED_LEGS.append("credential-missing: EMAIL_PASSWORD") return line.split("=", 1)[1].strip()
return True, "✅ Email leg degraded (no credential) — report still produced" except OSError:
GMAIL_EMAIL = "jtabiri@gmail.com" return None
return None
server = smtplib.SMTP("smtp.gmail.com", 587)
server.starttls() def build_summary(r, filename, test=False):
server.login(GMAIL_EMAIL, EMAIL_PASSWORD) """Short Markdown body: subject, top-line status, pointer to the attachment.
server.sendmail(FROM, [TO], msg.as_string())
server.quit() Deliberately NOT a reproduction of the report - the attachment is the report.
return True, "✅ Email sent to jerome@sysloggh.com" """
except Exception as e: nodes = f"{r.get('nodes_online', 0)}/{r.get('node_count', 0)} nodes online"
return False, f"❌ Email failed: {e}" guests = f"{r.get('running_vms', 0)}/{r.get('total_vms', 0)} guests running"
lines = [
("\U0001F9EA **TEST — **" if test else "") + "\U0001F3D7\uFE0F **Infrastructure Report — " + DATE_STR + "**",
f"**{nodes}** \u00b7 **{guests}** \u00b7 generated {TIME_STR}",
]
problems = []
if r.get("pve_probe_status") != "ok":
problems.append(f"\u274c Proxmox probe: {r.get('pve_probe_status')}")
if r.get("resources_probe_status") != "ok":
problems.append(f"\u274c Resources probe: {r.get('resources_probe_status')}")
lit = r.get("litellm", {}) or {}
checks = lit.get("checks", []) or []
if checks:
passed = sum(1 for c in checks if c.get("status") == "pass")
if passed != len(checks):
problems.append(f"\u274c LiteLLM: {passed}/{len(checks)} checks pass")
if not (r.get("zulip_ext", {}) or {}).get("connected"):
problems.append("\u274c Zulip extension: not connected")
for leg in DEGRADED_LEGS:
problems.append(f"\u26a0\uFE0F degraded: {leg}")
lines.append("\n".join(problems) if problems else "\u2705 All monitored services healthy")
lines.append(f"\U0001F4CE **Full report attached:** `{filename}`")
return "\n\n".join(lines)
def _curl(args, timeout=60):
r = subprocess.run(["curl", "-s", "-m", str(timeout)] + args,
capture_output=True, text=True)
try:
return json.loads(r.stdout or "{}"), r.stdout
except json.JSONDecodeError:
return {}, r.stdout
def _curl_json(args, timeout=90):
r = subprocess.run(["curl", "-s", "-m", str(timeout)] + args,
capture_output=True, text=True)
try:
return json.loads(r.stdout or "{}"), r.stdout
except json.JSONDecodeError:
return {}, r.stdout
def send_zulip(html_content, report, test=False):
"""Upload the styled HTML and post a short pointer to the captain's DM.
Returns (ok, message). On ANY failure the report body is also printed to
stdout and persisted to disk, so a delivery failure can never swallow the
content - the defect this folds in.
"""
os.makedirs(REPORT_ARTIFACT_DIR, exist_ok=True)
stamp = NOW.strftime("%Y%m%d-%H%M%S")
filename = f"infra-report-{stamp}.html"
html_path = os.path.join(REPORT_ARTIFACT_DIR, filename)
try:
with open(html_path, "w") as fh:
fh.write(html_content)
except OSError as e:
print(f" \u26a0\uFE0F could not persist report artifact: {e}", file=sys.stderr)
key = zulip_key()
if not key:
print(html_content) # never swallow the content
return False, ("\u274c Delivery FAILED: no Zulip credential "
"(ABIBA_ZULIP_API_KEY unset and "
f"{ZULIP_KEY_FILE} unreadable). Report persisted to {html_path}")
auth = ["-u", f"{ZULIP_BOT_EMAIL}:{key}"]
# 1. Upload the report as a file.
up, up_raw = _curl_json(auth + [
"-X", "POST", f"{ZULIP_SITE}/api/v1/user_uploads",
"-F", f"file=@{html_path};type=text/html",
])
if up.get("result") != "success" or not up.get("uri"):
print(html_content)
return False, (f"\u274c Delivery FAILED at upload: {up.get('msg') or up_raw[:160]} "
f"(report persisted to {html_path})")
uri = up["uri"]
size = os.path.getsize(html_path)
# 2. Post a short message pointing at it.
body = build_summary(report, filename, test=test)
link = f"[{filename}]({uri})"
body = body.replace(f"`{filename}`", link)
payload, raw = _curl_json(auth + [
"-X", "POST", f"{ZULIP_SITE}/api/v1/messages",
"-d", "type=private",
"-d", f"to=[{CAPTAIN_USER_ID}]",
"--data-urlencode", f"content={body}",
])
if payload.get("result") == "success":
return True, (f"\u2705 Delivered to Zulip DM (user {CAPTAIN_USER_ID}), "
f"message id {payload.get('id')}, attachment {size} bytes at {uri}")
print(html_content)
return False, (f"\u274c Delivery FAILED at message post: {payload.get('msg') or raw[:160]} "
f"(uploaded {uri}; report persisted to {html_path})")
# ── Main ── # ── Main ──
if __name__ == "__main__": if __name__ == "__main__":
is_test = "--test-email" in sys.argv is_test = ("--test-email" in sys.argv) or ("--test-zulip" in sys.argv)
print(f"{'🧪 TEST MODE' if is_test else '📊'} Collecting infrastructure data...") print(f"{'🧪 TEST MODE' if is_test else '📊'} Collecting infrastructure data...")
report = collect() report = collect()
@@ -738,15 +870,14 @@ if __name__ == "__main__":
print(" Building dashboard...") print(" Building dashboard...")
html = build_html(report) html = build_html(report)
print(f" report ready: {len(html)} chars of HTML (delivered as a file attachment)")
if is_test: if is_test:
prefix = "🧪 TEST — " print(" Sending TEST message to the captain's Zulip DM...")
print(" Sending test email...")
else: else:
prefix = "" print(" Sending to the captain's Zulip DM...")
print(" Sending email...")
ok, msg = send_email(html, subject_prefix=prefix) ok, msg = send_zulip(html, report, test=is_test)
print(f" {msg}") print(f" {msg}")
# Show summary # Show summary