Spaces:
Running
Running
Auto email to Brevo
Browse files- OPERATIONAL_AUXILIARIES.md +44 -7
- lifecycle_alerts_regression.py +183 -6
- src/submission/lifecycle_alerts.py +143 -24
OPERATIONAL_AUXILIARIES.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
## Scope and status
|
| 4 |
|
| 5 |
- Stage 1: implemented. Consent applies only to newly submitted private tokens; public submissions are unchanged.
|
| 6 |
-
- Stage 2: implemented, disabled by default. Frontend-only
|
| 7 |
- Stage 3: **deferred at the size/complexity gate**. No production exporter, GitHub client, backup thread or GitHub configuration has been added. Do not treat the synthetic example as a backup.
|
| 8 |
- No Worker changes, production reads/writes, real credentials, GPU execution, live emails, GitHub uploads, deployment or commits were used.
|
| 9 |
|
|
@@ -43,18 +43,24 @@ Set these in **Frontend**, not Worker:
|
|
| 43 |
| Variable | Type | Value |
|
| 44 |
| --- | --- | --- |
|
| 45 |
| `EVALUATOR_ALERTS_ENABLED` | Variable | `true`; absent/other values disable |
|
|
|
|
| 46 |
| `EVALUATOR_SPACE_ID` | Existing Variable | the existing managed Worker `owner/space` |
|
| 47 |
-
| `
|
| 48 |
| `EVALUATOR_ALERT_FROM` | Secret | one authorized sender email, without display-name syntax |
|
| 49 |
| `EVALUATOR_ALERT_TO` | Secret | one recipient email |
|
| 50 |
|
| 51 |
-
All values must be present and addresses valid for the helper to start. Incomplete configuration disables
|
|
|
|
|
|
|
| 52 |
|
| 53 |
### Exact files/functions and boundaries
|
| 54 |
|
| 55 |
`src/submission/lifecycle_alerts.py`:
|
| 56 |
|
|
|
|
|
|
|
| 57 |
- `_config`, `start`, `stop`: opt-in lifecycle for a single daemon sender per Frontend process.
|
|
|
|
| 58 |
- `observe`: copies only Space ID, allowlisted runtime stage, active-work boolean, restart count/exhaustion flag and observation timestamp into a bounded in-memory queue. No record, token, error text or submission content is passed.
|
| 59 |
- `Notifications.consume`: categorizes/deduplicates observations and persists a small outbox/incident latch.
|
| 60 |
- `Notifications.deliver`: best-effort email via a separate HTTP client. Connect timeout 10s, read/write/pool 20s each; these are phase timeouts, not a universal wall-clock guarantee.
|
|
@@ -74,13 +80,44 @@ The helper uses only observations from existing controller calls, not an indepen
|
|
| 74 |
|
| 75 |
### Persistence, retry and rollback
|
| 76 |
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
|
| 81 |
The sender processes at most one due delivery per poll. Pending receipts survive restart. An in-flight email may finish after shutdown; shutdown does not wait on it or delay existing controller shutdown. Receipts are capped at 1000 entries; completed/unknown/failed entries can be pruned on a new incident while category latches remain. If 1000 pending entries accumulate, notifications fail independently. Read size is capped at 2MB. Provider and FUSE failures remain possible; optional alerts are not a guaranteed external monitoring system.
|
| 82 |
|
| 83 |
-
Disable with `EVALUATOR_ALERTS_ENABLED=false` (or remove configuration) and redeploy Frontend when convenient. Do not restart Worker. Keep
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
## Stage 3 — gate results and explicit deferral
|
| 86 |
|
|
@@ -175,7 +212,7 @@ Restore must remain separately authorized: validate offline, reconstruct per-sou
|
|
| 175 |
## Offline verification
|
| 176 |
|
| 177 |
- `token_consent_regression.py`: 8/8 (strict consent, public/private paths, identity/Write role, encryption/recovery, all terminal token stripping, exception non-disclosure, UI input/output forwarding and reset).
|
| 178 |
-
- `lifecycle_alerts_regression.py`:
|
| 179 |
- Existing `lifecycle_regression.py`: 49/49; `identity_history_regression.py`: 21/21; `stable_identity_regression.py`: 27/27; `publication_order_regression.py`: 25/25.
|
| 180 |
- Existing `staging_regression.py`: 15/16; the previously known Future Prediction primary metric mismatch remains (`Worker manifest pmf` vs `Frontend pmf_novel`). It was not introduced or changed by this task.
|
| 181 |
- Existing `traceability_regression.py`: 33/34 in the full suite. `test_multiple_entries_keep_individual_scores_and_metadata` passes alone (1/1), but the full suite leaves `Model B` presentation metadata from the preceding metadata test in its shared fixture; the subsequent test expects `Method 0123456789`. Its setup does not clear presentation metadata. The involved publication/metadata readers and tests are unchanged; this fixture-isolation issue was not fixed as part of this task.
|
|
|
|
| 3 |
## Scope and status
|
| 4 |
|
| 5 |
- Stage 1: implemented. Consent applies only to newly submitted private tokens; public submissions are unchanged.
|
| 6 |
+
- Stage 2: implemented, disabled by default. Frontend-only Brevo notifications and an independently gated operator-shell test command.
|
| 7 |
- Stage 3: **deferred at the size/complexity gate**. No production exporter, GitHub client, backup thread or GitHub configuration has been added. Do not treat the synthetic example as a backup.
|
| 8 |
- No Worker changes, production reads/writes, real credentials, GPU execution, live emails, GitHub uploads, deployment or commits were used.
|
| 9 |
|
|
|
|
| 43 |
| Variable | Type | Value |
|
| 44 |
| --- | --- | --- |
|
| 45 |
| `EVALUATOR_ALERTS_ENABLED` | Variable | `true`; absent/other values disable |
|
| 46 |
+
| `EVALUATOR_ALERT_TEST_ENABLED` | Variable | default `false`; `true` enables the operator-shell test only |
|
| 47 |
| `EVALUATOR_SPACE_ID` | Existing Variable | the existing managed Worker `owner/space` |
|
| 48 |
+
| `EVALUATOR_ALERT_BREVO_API_KEY` | Secret | Brevo Transactional Email API key, not an SMTP password |
|
| 49 |
| `EVALUATOR_ALERT_FROM` | Secret | one authorized sender email, without display-name syntax |
|
| 50 |
| `EVALUATOR_ALERT_TO` | Secret | one recipient email |
|
| 51 |
|
| 52 |
+
All mail configuration values must be present and addresses valid for the helper to start; the two feature switches are independent and default off. Incomplete/invalid configuration disables sending. Use a sender registered and verified in Brevo; the operator must configure that verified address and Brevo enforces sender acceptance. No extra sender lookup API call is added. Existing `httpx` suffices; no dependency changes. The old `EVALUATOR_ALERT_RESEND_API_KEY` is ignored: there is no Resend fallback or dual send. Provider billing/account limits are external; no live delivery is claimed.
|
| 53 |
+
|
| 54 |
+
Transport is fixed to `POST https://api.brevo.com/v3/smtp/email`, with `api-key` authentication, JSON `sender.email`, `to[].email`, `subject`, and `textContent`. Redirects are disabled. HTTP **201** is treated as API acceptance; a validated, non-secret `messageId` is retained where provided. Missing/malformed IDs are not logged or treated as a reason to resend accepted mail. API acceptance is **not** inbox delivery. See [Brevo send-email API](https://developers.brevo.com/reference/send-transac-email) and [plain-text request guide](https://developers.brevo.com/docs/send-a-transactional-email).
|
| 55 |
|
| 56 |
### Exact files/functions and boundaries
|
| 57 |
|
| 58 |
`src/submission/lifecycle_alerts.py`:
|
| 59 |
|
| 60 |
+
For the Brevo migration specifically, only `_config`, `Notifications.load`, `Notifications.consume` (receipt metadata only) and `Notifications.deliver` change. Added functions are `_mail_config`, `_message_id`, `_send_brevo`, `_save_test_record`, `send_test_email` and `main`. Lifecycle categorization/continuity, `_run`, auxiliary locking, `observe`, `start` and `stop` remain unchanged. The other two changed files are `lifecycle_alerts_regression.py` and this document.
|
| 61 |
+
|
| 62 |
- `_config`, `start`, `stop`: opt-in lifecycle for a single daemon sender per Frontend process.
|
| 63 |
+
- `_mail_config`, `_send_brevo`, `_message_id`: shared finite-timeout transport/configuration and filtered provider receipt, used by production alerts and the operator test.
|
| 64 |
- `observe`: copies only Space ID, allowlisted runtime stage, active-work boolean, restart count/exhaustion flag and observation timestamp into a bounded in-memory queue. No record, token, error text or submission content is passed.
|
| 65 |
- `Notifications.consume`: categorizes/deduplicates observations and persists a small outbox/incident latch.
|
| 66 |
- `Notifications.deliver`: best-effort email via a separate HTTP client. Connect timeout 10s, read/write/pool 20s each; these are phase timeouts, not a universal wall-clock guarantee.
|
|
|
|
| 80 |
|
| 81 |
### Persistence, retry and rollback
|
| 82 |
|
| 83 |
+
Production alerts use only `/data/auxiliary/lifecycle-alerts/notifications.json` and `sender.lock` when enabled. No authoritative business file is changed. The JSON contains version, Space ID, three category latches and an outbox of allowlisted notification fields/receipts, including provider and optional filtered message ID; no API key, sender/recipient address or submission data. Writes use temp file, fsync and replace. Invalid state fails closed without overwriting it. Operator tests use the separate directory described below.
|
| 84 |
+
|
| 85 |
+
Delivery is persisted as `unknown` **before** calling Brevo. If a timeout, crash, **5xx**, or unexpected non-201 2xx occurs, it is not automatically resent: this trades possible missed email for duplicate avoidance. Definite **429** rate-limit rejection gets at most three total attempts (60s then 300s); incidents older than 23h are no longer sent/retried. This existing 23h bound is a notification-freshness rule, not a Brevo idempotency guarantee. Other failures are marked failed. Receipt-write failure after acceptance leaves the durable unknown receipt, also preventing resend.
|
| 86 |
+
|
| 87 |
+
The verified Brevo documentation describes `headers.idempotencyKey` for batch emails, not Resend's HTTP `Idempotency-Key`. This implementation sends a single plain-text message and **does not assume batch idempotency applies**, invent an idempotency header, or automatically retry ambiguous 5xx. The rate-limit retry cap/delays are unchanged; the more conservative 5xx treatment is the intentional provider-migration difference. See [Brevo batch idempotency scope](https://developers.brevo.com/docs/heterogenous-versions-batch-emails).
|
| 88 |
|
| 89 |
+
The reader accepts existing **v1 Resend state** and new **v2 state**. V2 adds `provider` and optional filtered `message_id` to each receipt. Migration preserves all IDs, latches, categories, delivery values, attempt counts and timestamps. Merely loading v1 does not rewrite it; conversion is persisted on a normal subsequent save. A v1 pending receipt with zero attempts may be sent once through Brevo. Any previously attempted or processed receipt is attributed to Resend; sent/unknown/failed receipts are not resent, and attempted pending Resend receipts are **parked**, not retried through Brevo. Parked pending receipts need separate operator review, not automatic replay. Normal pre-existing bounded-outbox pruning policy remains unchanged; migration itself does not delete/reset historical state.
|
| 90 |
|
| 91 |
The sender processes at most one due delivery per poll. Pending receipts survive restart. An in-flight email may finish after shutdown; shutdown does not wait on it or delay existing controller shutdown. Receipts are capped at 1000 entries; completed/unknown/failed entries can be pruned on a new incident while category latches remain. If 1000 pending entries accumulate, notifications fail independently. Read size is capped at 2MB. Provider and FUSE failures remain possible; optional alerts are not a guaranteed external monitoring system.
|
| 92 |
|
| 93 |
+
Disable production alerts with `EVALUATOR_ALERTS_ENABLED=false` (or remove configuration) and redeploy Frontend when convenient. Do not restart Worker. Keep auxiliary state to preserve incident deduplication if re-enabled. For rollback, disable both flags first and follow the v2 compatibility precautions below; never reset evaluator restart budget or delete Queue files as part of rollback.
|
| 94 |
+
|
| 95 |
+
### Operator-only test email
|
| 96 |
+
|
| 97 |
+
No established, server-authorized administrator UI was found. The publication subject allowlist is **not** an administrator role. Consequently no button, public HTTP endpoint, Gradio handler or new authentication system was added, and `app.py`/`evaluator_space.py` were not changed by this transport migration.
|
| 98 |
+
|
| 99 |
+
The entry point is `python -m src.submission.lifecycle_alerts send-test`, executed **only through an already authorized Frontend container terminal/SSH/operator execution route**. Authorization is the platform/OS permission to execute code in that container, not a caller-supplied username or `--admin` flag. Ordinary/unauthenticated website researchers have no registered route to this command. Anyone with container execution and server Secret access is already a trusted operator; the CLI does not authenticate an HF session itself. If your deployment has no restricted operator terminal/SSH route, this test capability is unavailable until that platform access is approved—do not expose the function via an unauthenticated endpoint.
|
| 100 |
+
|
| 101 |
+
Set `EVALUATOR_ALERT_TEST_ENABLED=true` in Frontend to enable the test; default is false. It uses the same Brevo key/from/to/Worker Space configuration, but production `EVALUATOR_ALERTS_ENABLED` can remain false while testing. There are no recipient/subject/key CLI overrides. Its subject is `[TEST] PhysInOne Leaderboard Email Alert`, and the body identifies a test, UTC timestamp and absence of any real GPU/Worker failure.
|
| 102 |
+
|
| 103 |
+
The command sends at most one API request per invocation. A separate nonblocking lock and atomic receipt under `/data/auxiliary/lifecycle-alert-tests/{test-send.lock,test-send.json}` enforce **one test attempt per 60 seconds across all operators** (stronger than a per-administrator limit), surviving process restart. Failures and ambiguous outcomes consume the cooldown; test sends do not use the production outbox, incident latches or restart state and are never automatically retried. The receipt contains only version, last attempt time, outcome and optional safe message ID. Invalid/unwritable test state fails closed. A concurrent command may report unavailable rather than waiting; it cannot send a second request.
|
| 104 |
+
|
| 105 |
+
Return values are sanitized: `api_accepted`, `unknown`, `failed`, `disabled`, `unconfigured`, `cooldown`, or `unavailable`. Exit code is zero only for API acceptance. A timeout/save failure after submission can leave outcome unknown; inspect Brevo/inbox before issuing another deliberate manual test. No receipt contains the API key or raw HTTP response.
|
| 106 |
+
|
| 107 |
+
**After-deployment operator procedure (not executed during development):**
|
| 108 |
+
|
| 109 |
+
1. Configure the Brevo API key as Frontend `EVALUATOR_ALERT_BREVO_API_KEY` Secret and set the existing sender/recipient Secrets. Use the already verified personal sender; keep keys out of commands and logs.
|
| 110 |
+
2. Explicitly enable `EVALUATOR_ALERT_TEST_ENABLED=true`. Deploy Frontend only when ready; no Worker deployment/restart is required. Production alert enablement is independent.
|
| 111 |
+
3. Enter the authorized **Frontend** container terminal, change to `/app`, and execute once:
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
python -m src.submission.lifecycle_alerts send-test
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
4. If the output reports `api_accepted`, check Brevo transactional activity and the configured recipient inbox/spam folder. Do not claim delivery based on the API result alone. For uncertain results, check activity before retrying, and wait at least 60 seconds.
|
| 118 |
+
5. Disable `EVALUATOR_ALERT_TEST_ENABLED` after testing. Changing the configured flag requires the normal Frontend configuration refresh/deployment; do not modify Bucket cooldown files to bypass it.
|
| 119 |
+
|
| 120 |
+
**Minimal deployment/rollback:** deploy the three changed Frontend files (alert module, offline tests and this document) and replace the key Secret; no new dependencies or Worker changes. Keep both optional flags false until deliberately enabled. The old provider key may be retained securely for a future reviewed rollback but is never read by this version. For immediate rollback, disable both features before reverting Frontend. Old Resend-only code cannot read persisted v2 receipts and fails closed; do not delete state or blindly restore an older snapshot, which could replay already processed incidents. Re-enabling Resend after rollback requires separately reviewed state compatibility handling; evaluation remains unaffected with notifications disabled.
|
| 121 |
|
| 122 |
## Stage 3 — gate results and explicit deferral
|
| 123 |
|
|
|
|
| 212 |
## Offline verification
|
| 213 |
|
| 214 |
- `token_consent_regression.py`: 8/8 (strict consent, public/private paths, identity/Write role, encryption/recovery, all terminal token stripping, exception non-disclosure, UI input/output forwarding and reset).
|
| 215 |
+
- `lifecycle_alerts_regression.py`: 26/26 after the Brevo migration (three events, continuity/restart/deduplication, Brevo payload/message ID, configuration validation, conservative 429/5xx/timeouts, v1 compatibility, independent operator-test cooldown/configuration/persistence failure and no public test registration).
|
| 216 |
- Existing `lifecycle_regression.py`: 49/49; `identity_history_regression.py`: 21/21; `stable_identity_regression.py`: 27/27; `publication_order_regression.py`: 25/25.
|
| 217 |
- Existing `staging_regression.py`: 15/16; the previously known Future Prediction primary metric mismatch remains (`Worker manifest pmf` vs `Frontend pmf_novel`). It was not introduced or changed by this task.
|
| 218 |
- Existing `traceability_regression.py`: 33/34 in the full suite. `test_multiple_entries_keep_individual_scores_and_metadata` passes alone (1/1), but the full suite leaves `Model B` presentation metadata from the preceding metadata test in its shared fixture; the subsequent test expects `Method 0123456789`. Its setup does not clear presentation metadata. The involved publication/metadata readers and tests are unchanged; this fixture-isolation issue was not fixed as part of this task.
|
lifecycle_alerts_regression.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
"""Offline email observations, durable deduplication and isolated delivery."""
|
| 2 |
import json
|
|
|
|
|
|
|
| 3 |
import os
|
| 4 |
from pathlib import Path
|
| 5 |
import socket
|
|
@@ -113,13 +115,20 @@ class AlertChecks(unittest.TestCase):
|
|
| 113 |
requests.append(request)
|
| 114 |
self.assertTrue(controller._mutex.acquire(blocking=False))
|
| 115 |
controller._mutex.release()
|
| 116 |
-
return httpx.Response(
|
| 117 |
self.deliver(respond)
|
| 118 |
self.deliver(respond)
|
| 119 |
self.assertEqual(len(requests), 1)
|
| 120 |
payload = json.loads(requests[0].content)
|
| 121 |
self.assertNotIn(self.config["key"], json.dumps(payload) + (self.directory / "notifications.json").read_text())
|
| 122 |
-
self.assertIn("process crash is not inferred", payload["
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
self.assertEqual(requests[0].extensions["timeout"], dict(connect=10, read=20, write=20, pool=20))
|
| 124 |
|
| 125 |
def test_ambiguous_timeout_not_resent_after_restart(self):
|
|
@@ -133,12 +142,12 @@ class AlertChecks(unittest.TestCase):
|
|
| 133 |
self.deliver(lambda request: self.fail("Ambiguous delivery must not resend"))
|
| 134 |
self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
|
| 135 |
|
| 136 |
-
def
|
| 137 |
self.sample("RUNTIME_ERROR")
|
| 138 |
keys = []
|
| 139 |
def fail(request):
|
| 140 |
-
keys.append(request.headers["
|
| 141 |
-
return httpx.Response(
|
| 142 |
self.deliver(fail)
|
| 143 |
self.deliver(fail)
|
| 144 |
self.assertEqual(len(keys), 1)
|
|
@@ -149,7 +158,7 @@ class AlertChecks(unittest.TestCase):
|
|
| 149 |
self.assertEqual(len(set(keys)), 1)
|
| 150 |
self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "failed")
|
| 151 |
|
| 152 |
-
def
|
| 153 |
self.sample("RUNTIME_ERROR")
|
| 154 |
self.now += 24 * 3600
|
| 155 |
self.deliver(lambda request: self.fail("Expired retry must not send"))
|
|
@@ -179,6 +188,174 @@ class AlertChecks(unittest.TestCase):
|
|
| 179 |
api.return_value.restart_space.assert_not_called()
|
| 180 |
api.return_value.set_space_sleep_time.assert_not_called()
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
if __name__ == "__main__":
|
| 184 |
with tempfile.TemporaryDirectory(prefix="physinone-alerts-import-") as root:
|
|
|
|
| 1 |
"""Offline email observations, durable deduplication and isolated delivery."""
|
| 2 |
import json
|
| 3 |
+
import ast
|
| 4 |
+
import copy
|
| 5 |
import os
|
| 6 |
from pathlib import Path
|
| 7 |
import socket
|
|
|
|
| 115 |
requests.append(request)
|
| 116 |
self.assertTrue(controller._mutex.acquire(blocking=False))
|
| 117 |
controller._mutex.release()
|
| 118 |
+
return httpx.Response(201, json={"messageId": "<synthetic@relay.example.org>"})
|
| 119 |
self.deliver(respond)
|
| 120 |
self.deliver(respond)
|
| 121 |
self.assertEqual(len(requests), 1)
|
| 122 |
payload = json.loads(requests[0].content)
|
| 123 |
self.assertNotIn(self.config["key"], json.dumps(payload) + (self.directory / "notifications.json").read_text())
|
| 124 |
+
self.assertIn("process crash is not inferred", payload["textContent"])
|
| 125 |
+
self.assertEqual(str(requests[0].url), "https://api.brevo.com/v3/smtp/email")
|
| 126 |
+
self.assertEqual(requests[0].headers["api-key"], self.config["key"])
|
| 127 |
+
self.assertNotIn("Authorization", requests[0].headers)
|
| 128 |
+
self.assertNotIn("Idempotency-Key", requests[0].headers)
|
| 129 |
+
self.assertEqual(payload["sender"], {"email": self.config["sender"]})
|
| 130 |
+
self.assertEqual(payload["to"], [{"email": self.config["recipient"]}])
|
| 131 |
+
self.assertEqual(self.notifications.state["outbox"][0]["message_id"], "<synthetic@relay.example.org>")
|
| 132 |
self.assertEqual(requests[0].extensions["timeout"], dict(connect=10, read=20, write=20, pool=20))
|
| 133 |
|
| 134 |
def test_ambiguous_timeout_not_resent_after_restart(self):
|
|
|
|
| 142 |
self.deliver(lambda request: self.fail("Ambiguous delivery must not resend"))
|
| 143 |
self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
|
| 144 |
|
| 145 |
+
def test_definite_rate_limit_failure_bounded(self):
|
| 146 |
self.sample("RUNTIME_ERROR")
|
| 147 |
keys = []
|
| 148 |
def fail(request):
|
| 149 |
+
keys.append(request.headers["api-key"])
|
| 150 |
+
return httpx.Response(429)
|
| 151 |
self.deliver(fail)
|
| 152 |
self.deliver(fail)
|
| 153 |
self.assertEqual(len(keys), 1)
|
|
|
|
| 158 |
self.assertEqual(len(set(keys)), 1)
|
| 159 |
self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "failed")
|
| 160 |
|
| 161 |
+
def test_expired_notification_not_sent(self):
|
| 162 |
self.sample("RUNTIME_ERROR")
|
| 163 |
self.now += 24 * 3600
|
| 164 |
self.deliver(lambda request: self.fail("Expired retry must not send"))
|
|
|
|
| 188 |
api.return_value.restart_space.assert_not_called()
|
| 189 |
api.return_value.set_space_sleep_time.assert_not_called()
|
| 190 |
|
| 191 |
+
def mail_environment(self, **changes):
|
| 192 |
+
values = {"EVALUATOR_SPACE_ID": "fixture/worker", "EVALUATOR_ALERT_BREVO_API_KEY": self.config["key"],
|
| 193 |
+
"EVALUATOR_ALERT_FROM": self.config["sender"], "EVALUATOR_ALERT_TO": self.config["recipient"],
|
| 194 |
+
"EVALUATOR_ALERTS_ENABLED": "true", "EVALUATOR_ALERT_TEST_ENABLED": "true"}
|
| 195 |
+
values.update(changes)
|
| 196 |
+
return patch.dict(os.environ, values, clear=True)
|
| 197 |
+
|
| 198 |
+
def test_brevo_configuration_validation_and_no_resend_fallback(self):
|
| 199 |
+
with self.mail_environment():
|
| 200 |
+
self.assertEqual(alerts._config(), self.config)
|
| 201 |
+
for variable, value in (("EVALUATOR_ALERT_BREVO_API_KEY", ""), ("EVALUATOR_ALERT_BREVO_API_KEY", "bad\nkey"),
|
| 202 |
+
("EVALUATOR_ALERT_FROM", "bad@example"), ("EVALUATOR_ALERT_TO", "a@example.org,b@example.org"),
|
| 203 |
+
("EVALUATOR_ALERT_TO", "a@example.org\nBcc:other@example.org"), ("EVALUATOR_ALERTS_ENABLED", "false")):
|
| 204 |
+
with patch.dict(os.environ, {variable: value}):
|
| 205 |
+
self.assertIsNone(alerts._config())
|
| 206 |
+
with self.mail_environment(EVALUATOR_ALERT_BREVO_API_KEY="", EVALUATOR_ALERT_RESEND_API_KEY="synthetic-old-key"):
|
| 207 |
+
self.assertIsNone(alerts._config())
|
| 208 |
+
|
| 209 |
+
def test_5xx_is_ambiguous_not_automatically_retried(self):
|
| 210 |
+
self.sample("RUNTIME_ERROR")
|
| 211 |
+
self.deliver(lambda request: httpx.Response(503, json={"secret": self.config["key"]}))
|
| 212 |
+
self.assertEqual(self.notifications.state["outbox"][0]["delivery"], "unknown")
|
| 213 |
+
self.notifications = alerts.Notifications(self.directory, "fixture/worker")
|
| 214 |
+
self.now += 300
|
| 215 |
+
self.deliver(lambda request: self.fail("Ambiguous 5xx must not resend"))
|
| 216 |
+
self.assertNotIn(self.config["key"], (self.directory / "notifications.json").read_text())
|
| 217 |
+
|
| 218 |
+
def test_201_without_trustworthy_message_id_is_not_resent(self):
|
| 219 |
+
self.sample("RUNTIME_ERROR")
|
| 220 |
+
self.deliver(lambda request: httpx.Response(201, json={"messageId": f"<{self.config['key']}@relay.example.org>"}))
|
| 221 |
+
item = self.notifications.state["outbox"][0]
|
| 222 |
+
self.assertEqual(item["delivery"], "sent")
|
| 223 |
+
self.assertIsNone(item["message_id"])
|
| 224 |
+
self.deliver(lambda request: self.fail("Accepted mail must not resend"))
|
| 225 |
+
self.assertNotIn(self.config["key"], (self.directory / "notifications.json").read_text())
|
| 226 |
+
|
| 227 |
+
def test_legacy_resend_processed_receipts_preserved_and_parked(self):
|
| 228 |
+
self.sample("RUNTIME_ERROR")
|
| 229 |
+
legacy = copy.deepcopy(self.notifications.state)
|
| 230 |
+
legacy["version"] = 1
|
| 231 |
+
template = legacy["outbox"][0]
|
| 232 |
+
template.pop("provider")
|
| 233 |
+
template.pop("message_id")
|
| 234 |
+
legacy["outbox"] = [dict(template, id=f"{index:032x}", delivery=delivery, tries=1)
|
| 235 |
+
for index, delivery in enumerate(("sent", "unknown", "failed", "pending"), 1)]
|
| 236 |
+
path = self.directory / "notifications.json"
|
| 237 |
+
path.write_text(json.dumps(legacy))
|
| 238 |
+
before = path.read_bytes()
|
| 239 |
+
self.notifications = alerts.Notifications(self.directory, "fixture/worker")
|
| 240 |
+
self.deliver(lambda request: self.fail("Migration must not replay processed incidents"))
|
| 241 |
+
self.assertEqual(path.read_bytes(), before)
|
| 242 |
+
self.assertEqual(self.notifications.state["latched"], legacy["latched"])
|
| 243 |
+
self.assertEqual([item["delivery"] for item in self.notifications.state["outbox"]], [item["delivery"] for item in legacy["outbox"]])
|
| 244 |
+
self.assertTrue(all(item["provider"] == "resend" for item in self.notifications.state["outbox"]))
|
| 245 |
+
self.sample("RUNTIME_ERROR", attempts=1)
|
| 246 |
+
self.assertEqual(len(self.notifications.state["outbox"]), 4)
|
| 247 |
+
|
| 248 |
+
def test_legacy_unattempted_pending_receipt_can_use_brevo_once(self):
|
| 249 |
+
self.sample("RUNTIME_ERROR")
|
| 250 |
+
legacy = copy.deepcopy(self.notifications.state)
|
| 251 |
+
legacy["version"] = 1
|
| 252 |
+
legacy["outbox"][0].pop("provider")
|
| 253 |
+
legacy["outbox"][0].pop("message_id")
|
| 254 |
+
(self.directory / "notifications.json").write_text(json.dumps(legacy))
|
| 255 |
+
self.notifications = alerts.Notifications(self.directory, "fixture/worker")
|
| 256 |
+
self.deliver(lambda request: httpx.Response(201, json={"messageId": "<synthetic@relay.example.org>"}))
|
| 257 |
+
item = self.notifications.state["outbox"][0]
|
| 258 |
+
self.assertEqual(item["id"], legacy["outbox"][0]["id"])
|
| 259 |
+
self.assertEqual(item["provider"], "brevo")
|
| 260 |
+
self.deliver(lambda request: self.fail("No duplicate after migration"))
|
| 261 |
+
|
| 262 |
+
def test_operator_test_disabled_or_unconfigured_does_not_write(self):
|
| 263 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERT_TEST_ENABLED="false"):
|
| 264 |
+
with patch.object(alerts, "_send_brevo") as transport:
|
| 265 |
+
self.assertEqual(alerts.send_test_email()["status"], "disabled")
|
| 266 |
+
transport.assert_not_called()
|
| 267 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERT_BREVO_API_KEY=""):
|
| 268 |
+
self.assertEqual(alerts.send_test_email()["status"], "unconfigured")
|
| 269 |
+
self.assertEqual(list(self.directory.iterdir()), [])
|
| 270 |
+
|
| 271 |
+
def test_operator_test_fixed_recipient_cooldown_and_incident_isolation(self):
|
| 272 |
+
self.sample("RUNTIME_ERROR")
|
| 273 |
+
incident = (self.directory / "notifications.json").read_bytes()
|
| 274 |
+
budget = self.directory / "queue/evaluator-restart.json"
|
| 275 |
+
budget.parent.mkdir()
|
| 276 |
+
budget.write_text('{"attempts":6,"blocked":"exhausted"}')
|
| 277 |
+
before = budget.read_bytes()
|
| 278 |
+
requests = []
|
| 279 |
+
client = httpx.Client
|
| 280 |
+
def respond(request):
|
| 281 |
+
requests.append(request)
|
| 282 |
+
payload = json.loads(request.content)
|
| 283 |
+
self.assertEqual(payload["subject"], "[TEST] PhysInOne Leaderboard Email Alert")
|
| 284 |
+
self.assertEqual(payload["to"], [{"email": self.config["recipient"]}])
|
| 285 |
+
self.assertIn("No real GPU or Worker failure occurred", payload["textContent"])
|
| 286 |
+
return httpx.Response(201, json={"messageId": "<test@relay.example.org>"})
|
| 287 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(EVALUATOR_ALERTS_ENABLED="false"), \
|
| 288 |
+
patch.object(alerts.time, "time", side_effect=lambda: self.now), \
|
| 289 |
+
patch.object(alerts.httpx, "Client", side_effect=lambda **options: client(transport=httpx.MockTransport(respond), **options)):
|
| 290 |
+
self.assertEqual(alerts.send_test_email()["status"], "api_accepted")
|
| 291 |
+
self.assertEqual(alerts.send_test_email()["status"], "cooldown")
|
| 292 |
+
self.now += 60
|
| 293 |
+
self.assertEqual(alerts.send_test_email()["status"], "api_accepted")
|
| 294 |
+
self.assertEqual(len(requests), 2)
|
| 295 |
+
self.assertEqual(budget.read_bytes(), before)
|
| 296 |
+
self.assertEqual((self.directory / "notifications.json").read_bytes(), incident)
|
| 297 |
+
receipt = json.loads((self.directory / "auxiliary/lifecycle-alert-tests/test-send.json").read_bytes())
|
| 298 |
+
self.assertEqual(receipt["message_id"], "<test@relay.example.org>")
|
| 299 |
+
self.assertNotIn(self.config["key"], json.dumps(receipt))
|
| 300 |
+
|
| 301 |
+
def test_failed_operator_send_single_attempt_and_consumes_cooldown(self):
|
| 302 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
|
| 303 |
+
patch.object(alerts.time, "time", return_value=self.now), \
|
| 304 |
+
patch.object(alerts, "_send_brevo", return_value=("retryable", None)) as transport:
|
| 305 |
+
self.assertEqual(alerts.send_test_email()["status"], "failed")
|
| 306 |
+
self.assertEqual(alerts.send_test_email()["status"], "cooldown")
|
| 307 |
+
transport.assert_called_once()
|
| 308 |
+
|
| 309 |
+
def test_test_persistence_failure_prevents_network_and_is_sanitized(self):
|
| 310 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
|
| 311 |
+
patch.object(alerts, "_save_test_record", side_effect=OSError(self.config["key"])), \
|
| 312 |
+
patch.object(alerts, "_send_brevo") as transport:
|
| 313 |
+
with self.assertLogs(alerts.logger, level="WARNING") as logs:
|
| 314 |
+
result = alerts.send_test_email()
|
| 315 |
+
self.assertEqual(result["status"], "unavailable")
|
| 316 |
+
transport.assert_not_called()
|
| 317 |
+
self.assertNotIn(self.config["key"], json.dumps(result) + "".join(logs.output))
|
| 318 |
+
|
| 319 |
+
def test_no_public_web_user_entry_or_arbitrary_recipient_cli_option(self):
|
| 320 |
+
app = (Path(__file__).parent / "app.py").read_text(encoding="utf-8")
|
| 321 |
+
tree = ast.parse(app)
|
| 322 |
+
names = {node.id for node in ast.walk(tree) if isinstance(node, ast.Name)}
|
| 323 |
+
attributes = {node.attr for node in ast.walk(tree) if isinstance(node, ast.Attribute)}
|
| 324 |
+
self.assertNotIn("send_test_email", names | attributes)
|
| 325 |
+
self.assertNotIn("send-test", app)
|
| 326 |
+
with patch("sys.argv", ["operator-command", "send-test", "--recipient", "ordinary@example.org"]), \
|
| 327 |
+
patch.object(alerts, "send_test_email") as send, patch("sys.stderr"):
|
| 328 |
+
with self.assertRaises(SystemExit) as result:
|
| 329 |
+
alerts.main()
|
| 330 |
+
self.assertEqual(result.exception.code, 2)
|
| 331 |
+
send.assert_not_called()
|
| 332 |
+
|
| 333 |
+
def test_operator_timeout_receipt_and_cooldown_survive_new_invocation(self):
|
| 334 |
+
client = httpx.Client
|
| 335 |
+
def timeout(request):
|
| 336 |
+
raise httpx.ReadTimeout(self.config["key"], request=request)
|
| 337 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
|
| 338 |
+
patch.object(alerts.time, "time", return_value=self.now), \
|
| 339 |
+
patch.object(alerts.httpx, "Client", side_effect=lambda **options: client(transport=httpx.MockTransport(timeout), **options)):
|
| 340 |
+
with self.assertLogs(alerts.logger, level="WARNING") as logs:
|
| 341 |
+
result = alerts.send_test_email()
|
| 342 |
+
self.assertEqual(result["status"], "unknown")
|
| 343 |
+
self.assertEqual(alerts.send_test_email()["status"], "cooldown")
|
| 344 |
+
raw = (self.directory / "auxiliary/lifecycle-alert-tests/test-send.json").read_text()
|
| 345 |
+
self.assertEqual(json.loads(raw)["delivery"], "unknown")
|
| 346 |
+
self.assertNotIn(self.config["key"], raw + json.dumps(result) + "".join(logs.output))
|
| 347 |
+
|
| 348 |
+
def test_malformed_operator_receipt_fails_closed(self):
|
| 349 |
+
path = self.directory / "auxiliary/lifecycle-alert-tests/test-send.json"
|
| 350 |
+
path.parent.mkdir(parents=True)
|
| 351 |
+
path.write_text('{"unexpected":"synthetic-secret"}')
|
| 352 |
+
before = path.read_bytes()
|
| 353 |
+
with patch.object(alerts, "DATA_ROOT", str(self.directory)), self.mail_environment(), \
|
| 354 |
+
patch.object(alerts, "_send_brevo") as transport:
|
| 355 |
+
self.assertEqual(alerts.send_test_email()["status"], "unavailable")
|
| 356 |
+
transport.assert_not_called()
|
| 357 |
+
self.assertEqual(path.read_bytes(), before)
|
| 358 |
+
|
| 359 |
|
| 360 |
if __name__ == "__main__":
|
| 361 |
with tempfile.TemporaryDirectory(prefix="physinone-alerts-import-") as root:
|
src/submission/lifecycle_alerts.py
CHANGED
|
@@ -29,22 +29,62 @@ _CATEGORIES = {"restart_budget_exhausted", "starting_30_minutes", "unexpected_st
|
|
| 29 |
_MAX_GAP = 45
|
| 30 |
|
| 31 |
|
| 32 |
-
def
|
| 33 |
-
if os.environ.get("EVALUATOR_ALERTS_ENABLED", "").lower() != "true":
|
| 34 |
-
return None
|
| 35 |
config = {name: os.environ.get(variable, "").strip() for name, variable in (
|
| 36 |
-
("space", "EVALUATOR_SPACE_ID"), ("key", "
|
| 37 |
("sender", "EVALUATOR_ALERT_FROM"), ("recipient", "EVALUATOR_ALERT_TO"))}
|
| 38 |
if not all(config.values()):
|
| 39 |
return None
|
| 40 |
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", config["space"]):
|
| 41 |
return None
|
|
|
|
|
|
|
| 42 |
for name in ("sender", "recipient"):
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
| 44 |
return None
|
| 45 |
return config
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
def observe(space_id: str, stage: str | None, active: bool | None, state: dict | None) -> None:
|
| 49 |
try:
|
| 50 |
config = _config()
|
|
@@ -107,14 +147,17 @@ class Notifications:
|
|
| 107 |
raise ValueError("Notification state limit")
|
| 108 |
state = json.loads(raw)
|
| 109 |
except FileNotFoundError:
|
| 110 |
-
state = {"version":
|
| 111 |
if (not isinstance(state, dict) or set(state) != {"version", "space", "latched", "outbox"}
|
| 112 |
-
or state["version"]
|
| 113 |
or not isinstance(state["latched"], list) or any(item not in _CATEGORIES for item in state["latched"])
|
| 114 |
or not isinstance(state["outbox"], list) or len(state["outbox"]) > 1000):
|
| 115 |
raise ValueError("Invalid notification state")
|
| 116 |
for item in state["outbox"]:
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
or not isinstance(item["id"], str) or not re.fullmatch(r"[0-9a-f]{32}", item["id"])
|
| 119 |
or item["category"] not in _CATEGORIES or item["stage"] not in _STAGES | {"UNKNOWN"}
|
| 120 |
or not isinstance(item["timestamp"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", item["timestamp"])
|
|
@@ -123,6 +166,15 @@ class Notifications:
|
|
| 123 |
or type(item["tries"]) is not int or not 0 <= item["tries"] <= 3
|
| 124 |
or type(item["next_try"]) not in {int, float} or not 0 <= item["next_try"] < float("inf")):
|
| 125 |
raise ValueError("Invalid notification receipt")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
self.state, self.loaded = state, True
|
| 127 |
|
| 128 |
def save(self):
|
|
@@ -179,7 +231,8 @@ class Notifications:
|
|
| 179 |
raise ValueError("Notification outbox full")
|
| 180 |
self.state["outbox"].append({"id": secrets.token_hex(16), "category": category,
|
| 181 |
"stage": stage or "UNKNOWN", "timestamp": datetime.fromtimestamp(observed, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 182 |
-
"attempts": sample["attempts"], "delivery": "pending", "tries": 0, "next_try": now
|
|
|
|
| 183 |
latches.add(category)
|
| 184 |
self.state["latched"] = sorted(latches)
|
| 185 |
if json.dumps(self.state, sort_keys=True) != before:
|
|
@@ -187,7 +240,8 @@ class Notifications:
|
|
| 187 |
|
| 188 |
def deliver(self, config: dict, now: float):
|
| 189 |
self.load()
|
| 190 |
-
item = next((item for item in self.state["outbox"] if item["delivery"] == "pending"
|
|
|
|
| 191 |
if item is None:
|
| 192 |
return
|
| 193 |
created = datetime.fromisoformat(item["timestamp"].replace("Z", "+00:00")).timestamp()
|
|
@@ -197,26 +251,18 @@ class Notifications:
|
|
| 197 |
return
|
| 198 |
item["tries"] += 1
|
| 199 |
item["delivery"] = "unknown"
|
|
|
|
| 200 |
self.save()
|
| 201 |
diagnostic = {"restart_budget_exhausted": "Automatic restart budget exhausted; operator review required.",
|
| 202 |
"starting_30_minutes": "Startup continuously observed for 30 minutes; no automated action taken.",
|
| 203 |
"unexpected_stop_or_runtime_error": "Stop or runtime error observed with active work; process crash is not inferred."}
|
| 204 |
text = (f"Worker Space: {self.space}\nCategory: {item['category']}\nUTC: {item['timestamp']}\n"
|
| 205 |
f"Runtime: {item['stage']}\nRestart attempts: {item['attempts']}\n{diagnostic[item['category']]}")
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
if 200 <= response.status_code < 300:
|
| 212 |
-
item["delivery"] = "sent"
|
| 213 |
-
elif response.status_code == 429 or response.status_code >= 500:
|
| 214 |
-
item["delivery"] = "pending" if item["tries"] < 3 else "failed"
|
| 215 |
-
item["next_try"] = now + (60 if item["tries"] == 1 else 300)
|
| 216 |
-
else:
|
| 217 |
-
item["delivery"] = "failed"
|
| 218 |
-
except Exception as error:
|
| 219 |
-
logger.warning("Auxiliary email outcome unknown (%s); not automatically resending", type(error).__name__)
|
| 220 |
self.save()
|
| 221 |
|
| 222 |
|
|
@@ -266,3 +312,76 @@ def start():
|
|
| 266 |
|
| 267 |
def stop():
|
| 268 |
_stop.set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
_MAX_GAP = 45
|
| 30 |
|
| 31 |
|
| 32 |
+
def _mail_config() -> dict | None:
|
|
|
|
|
|
|
| 33 |
config = {name: os.environ.get(variable, "").strip() for name, variable in (
|
| 34 |
+
("space", "EVALUATOR_SPACE_ID"), ("key", "EVALUATOR_ALERT_BREVO_API_KEY"),
|
| 35 |
("sender", "EVALUATOR_ALERT_FROM"), ("recipient", "EVALUATOR_ALERT_TO"))}
|
| 36 |
if not all(config.values()):
|
| 37 |
return None
|
| 38 |
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", config["space"]):
|
| 39 |
return None
|
| 40 |
+
if not re.fullmatch(r"[\x21-\x7e]{1,4096}", config["key"]):
|
| 41 |
+
return None
|
| 42 |
for name in ("sender", "recipient"):
|
| 43 |
+
address = config[name]
|
| 44 |
+
if (not re.fullmatch(r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+", address)
|
| 45 |
+
or len(address) > 254 or len(address.split("@")[0]) > 64
|
| 46 |
+
or address.startswith(".") or ".@" in address or ".." in address):
|
| 47 |
return None
|
| 48 |
return config
|
| 49 |
|
| 50 |
|
| 51 |
+
def _config() -> dict | None:
|
| 52 |
+
if os.environ.get("EVALUATOR_ALERTS_ENABLED", "").lower() != "true":
|
| 53 |
+
return None
|
| 54 |
+
return _mail_config()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _message_id(value, key="") -> str | None:
|
| 58 |
+
if (not isinstance(value, str) or not re.fullmatch(r"<[A-Za-z0-9._+:-]{1,200}@[A-Za-z0-9.-]{1,200}>", value)
|
| 59 |
+
or key and key in value or re.search(r"(?i)hf_|ghp_|github_pat_|xkeysib-|xsmtpsib-|bearer", value)):
|
| 60 |
+
return None
|
| 61 |
+
return value
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _send_brevo(config: dict, subject: str, text: str) -> tuple[str, str | None]:
|
| 65 |
+
try:
|
| 66 |
+
with httpx.Client(timeout=httpx.Timeout(20, connect=10), follow_redirects=False) as client:
|
| 67 |
+
response = client.post("https://api.brevo.com/v3/smtp/email",
|
| 68 |
+
headers={"api-key": config["key"], "Accept": "application/json"},
|
| 69 |
+
json={"sender": {"email": config["sender"]}, "to": [{"email": config["recipient"]}],
|
| 70 |
+
"subject": subject, "textContent": text})
|
| 71 |
+
if response.status_code == 201:
|
| 72 |
+
try:
|
| 73 |
+
body = response.json()
|
| 74 |
+
identifier = _message_id(body.get("messageId"), config["key"]) if isinstance(body, dict) else None
|
| 75 |
+
except ValueError:
|
| 76 |
+
identifier = None
|
| 77 |
+
return "sent", identifier
|
| 78 |
+
if response.status_code == 429:
|
| 79 |
+
return "retryable", None
|
| 80 |
+
if response.status_code >= 500 or 200 <= response.status_code < 300:
|
| 81 |
+
return "unknown", None
|
| 82 |
+
return "failed", None
|
| 83 |
+
except Exception as error:
|
| 84 |
+
logger.warning("Auxiliary email outcome unknown (%s); not automatically resending", type(error).__name__)
|
| 85 |
+
return "unknown", None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def observe(space_id: str, stage: str | None, active: bool | None, state: dict | None) -> None:
|
| 89 |
try:
|
| 90 |
config = _config()
|
|
|
|
| 147 |
raise ValueError("Notification state limit")
|
| 148 |
state = json.loads(raw)
|
| 149 |
except FileNotFoundError:
|
| 150 |
+
state = {"version": 2, "space": self.space, "latched": [], "outbox": []}
|
| 151 |
if (not isinstance(state, dict) or set(state) != {"version", "space", "latched", "outbox"}
|
| 152 |
+
or type(state["version"]) is not int or state["version"] not in {1, 2} or state["space"] != self.space
|
| 153 |
or not isinstance(state["latched"], list) or any(item not in _CATEGORIES for item in state["latched"])
|
| 154 |
or not isinstance(state["outbox"], list) or len(state["outbox"]) > 1000):
|
| 155 |
raise ValueError("Invalid notification state")
|
| 156 |
for item in state["outbox"]:
|
| 157 |
+
fields = {"id", "category", "stage", "timestamp", "attempts", "delivery", "tries", "next_try"}
|
| 158 |
+
if state["version"] == 2:
|
| 159 |
+
fields |= {"provider", "message_id"}
|
| 160 |
+
if (not isinstance(item, dict) or set(item) != fields
|
| 161 |
or not isinstance(item["id"], str) or not re.fullmatch(r"[0-9a-f]{32}", item["id"])
|
| 162 |
or item["category"] not in _CATEGORIES or item["stage"] not in _STAGES | {"UNKNOWN"}
|
| 163 |
or not isinstance(item["timestamp"], str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", item["timestamp"])
|
|
|
|
| 166 |
or type(item["tries"]) is not int or not 0 <= item["tries"] <= 3
|
| 167 |
or type(item["next_try"]) not in {int, float} or not 0 <= item["next_try"] < float("inf")):
|
| 168 |
raise ValueError("Invalid notification receipt")
|
| 169 |
+
if state["version"] == 2 and (item["provider"] not in {"brevo", "resend", "unassigned"}
|
| 170 |
+
or item["message_id"] is not None and _message_id(item["message_id"]) != item["message_id"]):
|
| 171 |
+
raise ValueError("Invalid notification provider receipt")
|
| 172 |
+
if state["version"] == 2 and item["provider"] == "unassigned" and (item["tries"] != 0 or item["delivery"] != "pending" or item["message_id"] is not None):
|
| 173 |
+
raise ValueError("Invalid unattempted notification receipt")
|
| 174 |
+
if state["version"] == 1:
|
| 175 |
+
for item in state["outbox"]:
|
| 176 |
+
item.update(provider="resend" if item["tries"] or item["delivery"] != "pending" else "unassigned", message_id=None)
|
| 177 |
+
state["version"] = 2
|
| 178 |
self.state, self.loaded = state, True
|
| 179 |
|
| 180 |
def save(self):
|
|
|
|
| 231 |
raise ValueError("Notification outbox full")
|
| 232 |
self.state["outbox"].append({"id": secrets.token_hex(16), "category": category,
|
| 233 |
"stage": stage or "UNKNOWN", "timestamp": datetime.fromtimestamp(observed, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
| 234 |
+
"attempts": sample["attempts"], "delivery": "pending", "tries": 0, "next_try": now,
|
| 235 |
+
"provider": "brevo", "message_id": None})
|
| 236 |
latches.add(category)
|
| 237 |
self.state["latched"] = sorted(latches)
|
| 238 |
if json.dumps(self.state, sort_keys=True) != before:
|
|
|
|
| 240 |
|
| 241 |
def deliver(self, config: dict, now: float):
|
| 242 |
self.load()
|
| 243 |
+
item = next((item for item in self.state["outbox"] if item["delivery"] == "pending"
|
| 244 |
+
and item["provider"] in {"brevo", "unassigned"} and item["next_try"] <= now), None)
|
| 245 |
if item is None:
|
| 246 |
return
|
| 247 |
created = datetime.fromisoformat(item["timestamp"].replace("Z", "+00:00")).timestamp()
|
|
|
|
| 251 |
return
|
| 252 |
item["tries"] += 1
|
| 253 |
item["delivery"] = "unknown"
|
| 254 |
+
item["provider"] = "brevo"
|
| 255 |
self.save()
|
| 256 |
diagnostic = {"restart_budget_exhausted": "Automatic restart budget exhausted; operator review required.",
|
| 257 |
"starting_30_minutes": "Startup continuously observed for 30 minutes; no automated action taken.",
|
| 258 |
"unexpected_stop_or_runtime_error": "Stop or runtime error observed with active work; process crash is not inferred."}
|
| 259 |
text = (f"Worker Space: {self.space}\nCategory: {item['category']}\nUTC: {item['timestamp']}\n"
|
| 260 |
f"Runtime: {item['stage']}\nRestart attempts: {item['attempts']}\n{diagnostic[item['category']]}")
|
| 261 |
+
outcome, item["message_id"] = _send_brevo(config, f"PhysInOne: {item['category']}", text)
|
| 262 |
+
item["delivery"] = outcome
|
| 263 |
+
if outcome == "retryable":
|
| 264 |
+
item["delivery"] = "pending" if item["tries"] < 3 else "failed"
|
| 265 |
+
item["next_try"] = now + (60 if item["tries"] == 1 else 300)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
self.save()
|
| 267 |
|
| 268 |
|
|
|
|
| 312 |
|
| 313 |
def stop():
|
| 314 |
_stop.set()
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _save_test_record(directory: Path, record: dict):
|
| 318 |
+
temporary = directory / f".test-send.{secrets.token_hex(8)}.tmp"
|
| 319 |
+
try:
|
| 320 |
+
with temporary.open("x", encoding="utf-8") as handle:
|
| 321 |
+
json.dump(record, handle, allow_nan=False)
|
| 322 |
+
handle.flush()
|
| 323 |
+
os.fsync(handle.fileno())
|
| 324 |
+
os.replace(temporary, directory / "test-send.json")
|
| 325 |
+
finally:
|
| 326 |
+
temporary.unlink(missing_ok=True)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def send_test_email() -> dict:
|
| 330 |
+
"""Operator-shell entry only; deliberately not registered with HTTP/Gradio."""
|
| 331 |
+
if os.environ.get("EVALUATOR_ALERT_TEST_ENABLED", "").lower() != "true":
|
| 332 |
+
return {"status": "disabled", "message": "Operator test email is disabled."}
|
| 333 |
+
config = _mail_config()
|
| 334 |
+
if config is None:
|
| 335 |
+
return {"status": "unconfigured", "message": "Brevo email configuration is missing or invalid."}
|
| 336 |
+
directory = Path(DATA_ROOT) / "auxiliary" / "lifecycle-alert-tests"
|
| 337 |
+
try:
|
| 338 |
+
directory.mkdir(parents=True, exist_ok=True)
|
| 339 |
+
with _auxiliary_lock(directory / "test-send.lock"):
|
| 340 |
+
path = directory / "test-send.json"
|
| 341 |
+
try:
|
| 342 |
+
with path.open("rb") as handle:
|
| 343 |
+
raw = handle.read(4097)
|
| 344 |
+
if len(raw) > 4096:
|
| 345 |
+
raise ValueError("Invalid test receipt")
|
| 346 |
+
previous = json.loads(raw)
|
| 347 |
+
if (not isinstance(previous, dict) or set(previous) != {"version", "last_attempt", "delivery", "message_id"}
|
| 348 |
+
or previous["version"] != 1 or type(previous["last_attempt"]) not in {int, float}
|
| 349 |
+
or not 0 <= previous["last_attempt"] < float("inf")
|
| 350 |
+
or previous["delivery"] not in {"sent", "unknown", "failed"}
|
| 351 |
+
or previous["message_id"] is not None and _message_id(previous["message_id"]) != previous["message_id"]):
|
| 352 |
+
raise ValueError("Invalid test receipt")
|
| 353 |
+
except FileNotFoundError:
|
| 354 |
+
previous = None
|
| 355 |
+
now = time.time()
|
| 356 |
+
if previous and now - previous["last_attempt"] < 60:
|
| 357 |
+
return {"status": "cooldown", "message": "Test email cooldown is active; wait at least 60 seconds between attempts."}
|
| 358 |
+
record = {"version": 1, "last_attempt": now, "delivery": "unknown", "message_id": None}
|
| 359 |
+
_save_test_record(directory, record)
|
| 360 |
+
text = ("This is a test email from the PhysInOne Leaderboard monitoring system. "
|
| 361 |
+
"No real GPU or Worker failure occurred. API acceptance does not confirm inbox delivery.\n"
|
| 362 |
+
f"UTC: {datetime.fromtimestamp(now, timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')}")
|
| 363 |
+
outcome, record["message_id"] = _send_brevo(config, "[TEST] PhysInOne Leaderboard Email Alert", text)
|
| 364 |
+
record["delivery"] = "failed" if outcome == "retryable" else outcome
|
| 365 |
+
_save_test_record(directory, record)
|
| 366 |
+
if outcome == "sent":
|
| 367 |
+
return {"status": "api_accepted", "message": "Brevo API accepted the test email. Confirm delivery separately in Brevo or the configured recipient inbox."}
|
| 368 |
+
if outcome == "unknown":
|
| 369 |
+
return {"status": "unknown", "message": "Test email outcome is uncertain; check Brevo or the inbox before another manual attempt."}
|
| 370 |
+
return {"status": "failed", "message": "Brevo did not accept the test email; review the configured credentials, verified sender and provider limits."}
|
| 371 |
+
except Exception as error:
|
| 372 |
+
logger.warning("Operator email test unavailable (%s)", type(error).__name__)
|
| 373 |
+
return {"status": "unavailable", "message": "Operator email test is unavailable; no evaluation or incident state was changed."}
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def main():
|
| 377 |
+
import argparse
|
| 378 |
+
parser = argparse.ArgumentParser(description="Operator-only email test; use an authorized Frontend container terminal.")
|
| 379 |
+
parser.add_argument("command", choices=["send-test"])
|
| 380 |
+
parser.parse_args()
|
| 381 |
+
result = send_test_email()
|
| 382 |
+
print(json.dumps(result))
|
| 383 |
+
return 0 if result["status"] == "api_accepted" else 1
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
if __name__ == "__main__":
|
| 387 |
+
raise SystemExit(main())
|