Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4320369bb9 | ||
|
|
3b74ca28d1 | ||
|
|
af9397d672 | ||
|
|
97dc2d772f | ||
|
|
2238777a2f | ||
|
|
6b2ba1bba5 | ||
|
|
a48b947242 | ||
|
|
ba9d29b4b9 | ||
|
|
d6376e5142 | ||
|
|
2ea6b4fc17 | ||
|
|
c6fd8eece3 | ||
|
|
4c715526ef | ||
|
|
308265e7ce | ||
|
|
88e9243ce4 | ||
|
|
13ac189365 | ||
|
|
2851a0cfc8 | ||
|
|
e0c9852de8 | ||
|
|
bf3a1ba523 | ||
|
|
f230812e3a | ||
|
|
96769a103f | ||
|
|
d697baa7b6 | ||
|
|
fb7f351a2b | ||
|
|
e42b970dec | ||
|
|
eadb927ec1 | ||
|
|
d4e238047d | ||
|
|
73d5097555 | ||
|
|
c460ef905c | ||
|
|
d99b552448 | ||
|
|
fc0cd7a032 | ||
|
|
0a41a2d584 | ||
|
|
de32f54337 |
@@ -24,7 +24,7 @@ Runs every 4 hours (2, 6, 10, 14, 18, 22 UTC at :35) via cron (`35 2,6,10,14,18,
|
|||||||
## Requires
|
## Requires
|
||||||
|
|
||||||
- **LiteLLM admin key** for key validation (retrieved from `/root/.pi/agent/env.sh`)
|
- **LiteLLM admin key** for key validation (retrieved from `/root/.pi/agent/env.sh`)
|
||||||
- **SSH access** to GPU hosts (.8, .110, .15) and agent CTs (.122, .129, .114, .24)
|
- **SSH access** to GPU hosts — `llmuser` on .8 (owns `llama-server`), `root` on .110 and .15 — and agent CTs (.122, .129, .114, .24)
|
||||||
- **Python 3** for script execution
|
- **Python 3** for script execution
|
||||||
- **Network access** to LiteLLM (:4000), GPU exporters (:9400), and gateway endpoints
|
- **Network access** to LiteLLM (:4000), GPU exporters (:9400), and gateway endpoints
|
||||||
|
|
||||||
|
|||||||
+36
-17
@@ -94,7 +94,16 @@ def audit(path):
|
|||||||
cfg = yaml.safe_load(f)
|
cfg = yaml.safe_load(f)
|
||||||
|
|
||||||
model = cfg.get("model", {})
|
model = cfg.get("model", {})
|
||||||
fb = cfg.get("fallback_providers", {})
|
fb_raw = cfg.get("fallback_providers", {})
|
||||||
|
# Normalize: fallback_providers may be a dict (single provider) or a list of dicts
|
||||||
|
# (one entry per fallback). Both shapes are valid; we must handle both without crashing.
|
||||||
|
if isinstance(fb_raw, dict):
|
||||||
|
fb_entries = [fb_raw]
|
||||||
|
elif isinstance(fb_raw, list):
|
||||||
|
fb_entries = fb_raw
|
||||||
|
else:
|
||||||
|
fb_entries = [fb_raw] # Let it fail the check below as malformed
|
||||||
|
fb = fb_entries[0] if fb_entries else {}
|
||||||
comp = cfg.get("compression", {})
|
comp = cfg.get("compression", {})
|
||||||
aux = cfg.get("auxiliary", {})
|
aux = cfg.get("auxiliary", {})
|
||||||
deleg = cfg.get("delegation", {})
|
deleg = cfg.get("delegation", {})
|
||||||
@@ -207,22 +216,32 @@ def audit(path):
|
|||||||
"Rule 14",
|
"Rule 14",
|
||||||
f"delegation.provider must be 'harness' (got {deleg.get('provider')!r})",
|
f"delegation.provider must be 'harness' (got {deleg.get('provider')!r})",
|
||||||
)
|
)
|
||||||
check(
|
# Check each fallback entry. A malformed entry (not a mapping) is a VIOLATION, not a crash.
|
||||||
fb.get("provider") == "deepseek",
|
for idx, entry in enumerate(fb_entries):
|
||||||
"Rule 14",
|
prefix = f"fallback_providers[{idx}]"
|
||||||
f"fallback_providers.provider must be 'deepseek' (got {fb.get('provider')!r}) — "
|
if not isinstance(entry, dict):
|
||||||
f"true fallback diversity, not same endpoint as primary",
|
check(
|
||||||
)
|
False,
|
||||||
check(
|
"Rule 14",
|
||||||
fb.get("model") == "deepseek-v4-flash",
|
f"{prefix} must be a mapping (got {type(entry).__name__})",
|
||||||
"Rule 14",
|
)
|
||||||
f"fallback_providers.model must be 'deepseek-v4-flash' (got {fb.get('model')!r})",
|
continue
|
||||||
)
|
check(
|
||||||
check(
|
entry.get("provider") == "deepseek",
|
||||||
fb.get("api_key_env") == "DEEPSEEK_API_KEY",
|
"Rule 14",
|
||||||
"Rule 14",
|
f"{prefix}.provider must be 'deepseek' (got {entry.get('provider')!r}) — "
|
||||||
f"fallback_providers.api_key_env must be DEEPSEEK_API_KEY (got {fb.get('api_key_env')!r})",
|
f"true fallback diversity, not same endpoint as primary",
|
||||||
)
|
)
|
||||||
|
check(
|
||||||
|
entry.get("model") == "deepseek-v4-flash",
|
||||||
|
"Rule 14",
|
||||||
|
f"{prefix}.model must be 'deepseek-v4-flash' (got {entry.get('model')!r})",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
entry.get("api_key_env") == "DEEPSEEK_API_KEY",
|
||||||
|
"Rule 14",
|
||||||
|
f"{prefix}.api_key_env must be DEEPSEEK_API_KEY (got {entry.get('api_key_env')!r})",
|
||||||
|
)
|
||||||
|
|
||||||
# --- custom_providers sanity ---
|
# --- custom_providers sanity ---
|
||||||
check(
|
check(
|
||||||
|
|||||||
+15
-11
@@ -643,7 +643,7 @@ contracts:
|
|||||||
timeout: 120
|
timeout: 120
|
||||||
requires:
|
requires:
|
||||||
- Zulip API key for abiba-bot@chat.sysloggh.net
|
- Zulip API key for abiba-bot@chat.sysloggh.net
|
||||||
- SSH access to amdpve (192.168.68.15) for Tanko (CT 112) and the Agent Zero Docker host (.14)
|
- SSH access to minipve (192.168.68.12) for Tanko (CT 112) and the Agent Zero Docker host (.14)
|
||||||
verification:
|
verification:
|
||||||
postconditions:
|
postconditions:
|
||||||
- check: bot registration active
|
- check: bot registration active
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -414,7 +414,7 @@ one-off GPU builds. No automated post-migration cleanup was in place.
|
|||||||
| 108 | media | storepve | lxc | ✅ reachable |
|
| 108 | media | storepve | lxc | ✅ reachable |
|
||||||
| 110 | gitea | minipve | lxc | ✅ reachable |
|
| 110 | gitea | minipve | lxc | ✅ reachable |
|
||||||
| 111 | tdunna | **storepve** | lxc | ⛔ **REPORT-ONLY** (192.168.68.129, Theo's box — no GC at any level) |
|
| 111 | tdunna | **storepve** | lxc | ⛔ **REPORT-ONLY** (192.168.68.129, Theo's box — no GC at any level) |
|
||||||
| 112 | tanko | amdpve | lxc | ✅ reachable |
|
| 112 | tanko | minipve | lxc | ✅ reachable |
|
||||||
| 113 | baggy | amdpve | lxc | ✅ reachable |
|
| 113 | baggy | amdpve | lxc | ✅ reachable |
|
||||||
| 115 | scottdenya | amdpve | lxc | ✅ reachable |
|
| 115 | scottdenya | amdpve | lxc | ✅ reachable |
|
||||||
| 116 | syslog-api | minipve | lxc | ✅ reachable |
|
| 116 | syslog-api | minipve | lxc | ✅ reachable |
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# Probe-drift round 2 — per-leg before/after evidence
|
# Probe-drift round 2 — per-leg before/after evidence
|
||||||
|
|
||||||
|
> **Historical record** — 2026-09-28: The lines below that describe tanko as
|
||||||
|
> "DSH (DeepSeek Harness)" only reflect what the check reported when it was
|
||||||
|
> running. Tanko's runtime was later found to be **hybrid (DSH + Hermes)** —
|
||||||
|
> the check had a `/root/` hardcoding bug that made it probe the wrong home
|
||||||
|
> directory and report `wrapper-missing:tanko` for an agent with a working
|
||||||
|
> wrapper. This document records the observed output, not the underlying
|
||||||
|
> truth; see `fix/agent-health-root-hardcoding-20260928` for the correction.
|
||||||
|
|
||||||
|
|
||||||
**Date:** 2026-09-10
|
**Date:** 2026-09-10
|
||||||
**Worktree (absolute execution path):** `/root/.treehouse/prose-contracts-9ce5f3/3/prose-contracts`
|
**Worktree (absolute execution path):** `/root/.treehouse/prose-contracts-9ce5f3/3/prose-contracts`
|
||||||
**Branch:** `fm/probe-drift-round2-20260909`
|
**Branch:** `fm/probe-drift-round2-20260909`
|
||||||
|
|||||||
@@ -70,10 +70,10 @@ Agent (systemd) → LITELLM_API_KEY → LiteLLM (:116/v1) → GPU (llama-server)
|
|||||||
|
|
||||||
## Config Pattern — Mandatory Fields
|
## Config Pattern — Mandatory Fields
|
||||||
|
|
||||||
### For Hermes Agents (Mumuni, Koonimo)
|
### For Hermes Agents (Mumuni, Koonimo, Tanko-hybrid)
|
||||||
|
|
||||||
Every Hermes agent's `/root/.hermes/config.yaml` (or `/home/jerome/.hermes/config.yaml`) MUST have:
|
Every Hermes agent's `/root/.hermes/config.yaml` (or `/home/jerome/.hermes/config.yaml`) MUST have:
|
||||||
(Tanko is excluded — migrated to DSH/DeepSeek Harness on 2026-08-27, no longer uses Hermes config.)
|
(Tanko is hybrid — runs both DSH and Hermes since 2026-08-27, so its Hermes config is also checked.)
|
||||||
|
|
||||||
### 1. Main Model
|
### 1. Main Model
|
||||||
```yaml
|
```yaml
|
||||||
@@ -297,7 +297,7 @@ Run the consolidated health check:
|
|||||||
```bash
|
```bash
|
||||||
python3 /root/scripts/agent-health-check.py
|
python3 /root/scripts/agent-health-check.py
|
||||||
```
|
```
|
||||||
This validates all 4 LiteLLM keys, detects GPU port conflicts (ghost processes),
|
This validates each agent's live LiteLLM key against the gateway, including tanko, which runs HYBRID (DSH + Hermes) since 2026-08-27; detects GPU port conflicts (ghost processes),
|
||||||
verifies gateway liveness, confirms Zulip streaming (`edit_message` present),
|
verifies gateway liveness, confirms Zulip streaming (`edit_message` present),
|
||||||
and counts recent errors. Non-disruptive — never restarts anything.
|
and counts recent errors. Non-disruptive — never restarts anything.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ description: >
|
|||||||
Standard Hermes configuration template for Syslog Solution LLC agents.
|
Standard Hermes configuration template for Syslog Solution LLC agents.
|
||||||
Enforces shared infrastructure setup (Firecrawl, SearXNG, local models,
|
Enforces shared infrastructure setup (Firecrawl, SearXNG, local models,
|
||||||
RA-H OS MCP) while keeping agent-specific API keys and model choices.
|
RA-H OS MCP) while keeping agent-specific API keys and model choices.
|
||||||
|
UPDATED 2026-09-27: Clarified the Auxiliary Tasks policy — light aux (vision,
|
||||||
|
web_extract/browsing) -> gpu-vision (RTX 5070); context-heavy aux (compression) ->
|
||||||
|
syslog-auto (2026-07-23 decision, Rule 7). Removed the false "one model for all
|
||||||
|
auxiliary" / "never syslog-auto" claim; stated gpu-dense + strix-moe are the reasoning
|
||||||
|
hosts and aux should not be pinned to them. Now matches audit-hermes-config.py line-for-line.
|
||||||
UPDATED 2026-08-07: Added litellm MCP server entry; updated Rule 15 (MCP Validation)
|
UPDATED 2026-08-07: Added litellm MCP server entry; updated Rule 15 (MCP Validation)
|
||||||
to enforce REAL key headers (not env-vars) from the 2026-08-07 keyless-MCP incident.
|
to enforce REAL key headers (not env-vars) from the 2026-08-07 keyless-MCP incident.
|
||||||
Added Rule 12 (Context-Issue Diagnostic) + Rule 13 (.env fallback enforcement) from the
|
Added Rule 12 (Context-Issue Diagnostic) + Rule 13 (.env fallback enforcement) from the
|
||||||
@@ -167,13 +172,16 @@ compression:
|
|||||||
abort_on_summary_failure: false
|
abort_on_summary_failure: false
|
||||||
|
|
||||||
# ─── Auxiliary Tasks (CONSISTENCY RULE) ───
|
# ─── Auxiliary Tasks (CONSISTENCY RULE) ───
|
||||||
# All auxiliary services MUST use identical model, base_url, and api_key_env:
|
# Auxiliary tasks split into TWO model classes — do NOT assume one model for all:
|
||||||
# model: gpu-vision # stable alias (NOT a raw model name)
|
# Light auxiliary (vision, web_extract/browsing) -> model: gpu-vision # RTX 5070
|
||||||
|
# Keeps the reasoning hosts (gpu-dense / strix-moe) free for agent prompts.
|
||||||
|
# Context-heavy auxiliary (compression) -> model: syslog-auto # weighted pool
|
||||||
|
# Deliberate per the 2026-07-23 OPERATIONAL DECISION in Rule 7: summarization
|
||||||
|
# runs against long histories and must be able to use the pool.
|
||||||
|
# Do NOT pin auxiliary work to the reasoning hosts (gpu-dense / strix-moe).
|
||||||
|
# All auxiliary services share identical ROUTING (base_url + api_key_env), not model:
|
||||||
# base_url: http://192.168.68.116/litellm/v1 # Rule 5 (2026-08-09): canonical authenticated; /v1 also OK
|
# base_url: http://192.168.68.116/litellm/v1 # Rule 5 (2026-08-09): canonical authenticated; /v1 also OK
|
||||||
# api_key_env: LITELLM_API_KEY
|
# api_key_env: LITELLM_API_KEY
|
||||||
# Do NOT use syslog-auto for auxiliary tasks — it routes to the primary GPU.
|
|
||||||
# gpu-vision = RTX 5070 (12B), freeing the Strix Halo for agent reasoning.
|
|
||||||
# Heavy aux (delegation, x_search) use gpu-dense (RTX 3090) instead.
|
|
||||||
# NEVER use retired model names (qwen3.6-27B-code, qwen3.6-35B-udq4; gemma-4-12b is retired
|
# NEVER use retired model names (qwen3.6-27B-code, qwen3.6-35B-udq4; gemma-4-12b is retired
|
||||||
# and no longer resolves) in agent configs — use the stable aliases so model swaps don't break agents.
|
# and no longer resolves) in agent configs — use the stable aliases so model swaps don't break agents.
|
||||||
auxiliary:
|
auxiliary:
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ connectivity recovery including end-to-end DM validation.
|
|||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
| Param | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `target` | string | yes | — | Agent name: `mumuni`, `koby`, or `shumba` (Tanko excluded — on DSH since 2026-08-27, no Hermes plugin) |
|
| `target` | string | yes | — | Agent name: `mumuni`, `koby`, or `shumba` (Tanko excluded — hybrid (DSH + Hermes) since 2026-08-27, no Hermes plugin) |
|
||||||
| `branch` | string | no | `master` | Git branch to pull (overridable for pinning) |
|
| `branch` | string | no | `master` | Git branch to pull (overridable for pinning) |
|
||||||
|
|
||||||
## Maintains
|
## Maintains
|
||||||
@@ -55,7 +55,7 @@ connectivity recovery including end-to-end DM validation.
|
|||||||
|
|
||||||
| Host | CT | Proxmox | IP (direct) | Hermes Home | User |
|
| Host | CT | Proxmox | IP (direct) | Hermes Home | User |
|
||||||
|------|-----|---------|-------------|-------------|------|
|
|------|-----|---------|-------------|-------------|------|
|
||||||
| Tanko | CT112 | amdpve | 192.168.68.122 | /home/jerome/.hermes | jerome | *(DSH since 2026-08-27 — historical, plugin retired on this host)* |
|
| Tanko | CT112 | minipve | 192.168.68.122 | /home/jerome/.hermes | jerome | *(hybrid (DSH + Hermes) since 2026-08-27 — historical, plugin retired on this host)* |
|
||||||
| Koby | CT111 | storepve | 192.168.68.129 | /root/.hermes | root |
|
| Koby | CT111 | storepve | 192.168.68.129 | /root/.hermes | root |
|
||||||
| Shumba | — | — | 192.168.68.119 | /home/lucky/.hermes | lucky |
|
| Shumba | — | — | 192.168.68.119 | /home/lucky/.hermes | lucky |
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ connectivity recovery including end-to-end DM validation.
|
|||||||
### Step 1: Resolve Target
|
### Step 1: Resolve Target
|
||||||
|
|
||||||
Map `target` to host, CT ID, hermes_home, and user from the live-state table.
|
Map `target` to host, CT ID, hermes_home, and user from the live-state table.
|
||||||
For CT112 route through `ssh root@amdpve`; for CT111 route through `ssh root@storepve` — then `pct exec <id>`.
|
For CT112 route through `ssh root@minipve`; for CT111 route through `ssh root@storepve` — then `pct exec <id>`.
|
||||||
|
|
||||||
### Step 2: Pull Latest Plugin Source
|
### Step 2: Pull Latest Plugin Source
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ cp plugins/platforms/zulip/adapter.py \
|
|||||||
{{hermes_home}}/hermes-agent/plugins/platforms/zulip/
|
{{hermes_home}}/hermes-agent/plugins/platforms/zulip/
|
||||||
|
|
||||||
# Fix ownership (was Tanko-only, runs as jerome user)
|
# Fix ownership (was Tanko-only, runs as jerome user)
|
||||||
# RETIRED 2026-08-27: tanko no longer uses the Hermes Zulip plugin (DSH).
|
# RETIRED 2026-08-27: tanko no longer uses the Hermes Zulip plugin (hybrid: DSH + Hermes).
|
||||||
[ "{{target}}" = "tanko" ] && chown -R jerome:jerome \
|
[ "{{target}}" = "tanko" ] && chown -R jerome:jerome \
|
||||||
{{hermes_home}}/hermes-agent/plugins/platforms/zulip/
|
{{hermes_home}}/hermes-agent/plugins/platforms/zulip/
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ gateway restart, and connection validation.
|
|||||||
|
|
||||||
| Param | Type | Required | Default | Description |
|
| Param | Type | Required | Default | Description |
|
||||||
|-------|------|----------|---------|-------------|
|
|-------|------|----------|---------|-------------|
|
||||||
| `target` | string | yes | — | Agent name: `mumuni`, `koby`, or `shumba` (Tanko excluded — DSH since 2026-08-27) |
|
| `target` | string | yes | — | Agent name: `mumuni`, `koby`, or `shumba` (Tanko excluded — hybrid (DSH + Hermes) since 2026-08-27) |
|
||||||
|
|
||||||
## Maintains
|
## Maintains
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ gateway restart, and connection validation.
|
|||||||
### Step 1: Locate Target
|
### Step 1: Locate Target
|
||||||
|
|
||||||
Map `target` to connectivity parameters from the live-state table above.
|
Map `target` to connectivity parameters from the live-state table above.
|
||||||
For CT112 route through `ssh root@amdpve`; for CT111 route through `ssh root@storepve` — then `pct exec <id>`.
|
For CT112 route through `ssh root@minipve`; for CT111 route through `ssh root@storepve` — then `pct exec <id>`.
|
||||||
|
|
||||||
### Step 2: Deploy Zulip Adapter
|
### Step 2: Deploy Zulip Adapter
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ cp zulip-platform-plugins/plugins/platforms/zulip/adapter.py \
|
|||||||
zulip-platform-plugins/plugins/platforms/zulip/plugin.yaml \
|
zulip-platform-plugins/plugins/platforms/zulip/plugin.yaml \
|
||||||
<HERMES_HOME>/hermes-agent/plugins/platforms/zulip/
|
<HERMES_HOME>/hermes-agent/plugins/platforms/zulip/
|
||||||
|
|
||||||
# Fix ownership (was Tanko-only; RETIRED 2026-08-27 — tanko on DSH, no Hermes plugin)
|
# Fix ownership (was Tanko-only; RETIRED 2026-08-27 — tanko on hybrid (DSH + Hermes), no Hermes plugin)
|
||||||
chown -R jerome:jerome <HERMES_HOME>/hermes-agent/plugins/platforms/zulip/ # Tanko only (historical)
|
chown -R jerome:jerome <HERMES_HOME>/hermes-agent/plugins/platforms/zulip/ # Tanko only (historical)
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
|
|||||||
@@ -105,8 +105,8 @@ description: >
|
|||||||
|
|
||||||
| Node | IP | CPU | RAM | VMs/CTs | Role |
|
| Node | IP | CPU | RAM | VMs/CTs | Role |
|
||||||
|------|----|-----|-----|---------|------|
|
|------|----|-----|-----|---------|------|
|
||||||
| minipve | .12 | 16C | 30GB | abiba, authentik, gitea, syslog-api, infisical-vault, jitsi | Auth, git, messaging |
|
| minipve | .12 | 16C | 30GB | abiba, tanko, authentik, gitea, syslog-api, infisical-vault, jitsi | Auth, git, messaging |
|
||||||
| amdpve | .15 | 32C | 62GB | kagentz, tanko, baggy, scottdenya, adguard2 | Agents, compute |
|
| amdpve | .15 | 32C | 62GB | kagentz, baggy, scottdenya, adguard2 | Agents, compute |
|
||||||
| storepve | .6 | 28C | 31GB | docker-vm, ra-h-os, PBS, media, jdownloader, zulip, tdunna | Docker, storage, chat |
|
| storepve | .6 | 28C | 31GB | docker-vm, ra-h-os, PBS, media, jdownloader, zulip, tdunna | Docker, storage, chat |
|
||||||
| acerpve | .9 | 28C | 31GB | llm-gpu | GPU VMs |
|
| acerpve | .9 | 28C | 31GB | llm-gpu | GPU VMs |
|
||||||
| ocupve | .5 | 12C | 14GB | ocu-llm | GPU VMs |
|
| ocupve | .5 | 12C | 14GB | ocu-llm | GPU VMs |
|
||||||
@@ -682,7 +682,7 @@ ssh root@192.168.68.110 "systemctl restart llama-server"
|
|||||||
| 109 | docker-vm | storepve | .7 | Docker host | ❌ |
|
| 109 | docker-vm | storepve | .7 | Docker host | ❌ |
|
||||||
| 110 | gitea | minipve | **.17** | Git | ❌ |
|
| 110 | gitea | minipve | **.17** | Git | ❌ |
|
||||||
| 111 | tdunna | storepve | .129 | Hermes agent — ⛔ REPORT-ONLY (Theo's box, no GC) | ✅ |
|
| 111 | tdunna | storepve | .129 | Hermes agent — ⛔ REPORT-ONLY (Theo's box, no GC) | ✅ |
|
||||||
| 112 | tanko | amdpve | .122 | DSH (DeepSeek Harness) agent | ✅ |
|
| 112 | tanko | minipve | .122 | hybrid (DSH + Hermes) agent | ✅ |
|
||||||
| 113 | baggy | amdpve | .114 | Hermes agent | ✅ |
|
| 113 | baggy | amdpve | .114 | Hermes agent | ✅ |
|
||||||
| 115 | scottdenya | amdpve | .75 | Denya OneCare | ❌ |
|
| 115 | scottdenya | amdpve | .75 | Denya OneCare | ❌ |
|
||||||
| 116 | syslog-api | minipve | .116 | LiteLLM + Grafana | ❌ |
|
| 116 | syslog-api | minipve | .116 | LiteLLM + Grafana | ❌ |
|
||||||
@@ -712,7 +712,7 @@ Source of truth: `/root/scripts/pct-run.sh` or `prose-contracts/scripts/pct-run.
|
|||||||
| 100 | abiba | minipve | `pct-run 100` |
|
| 100 | abiba | minipve | `pct-run 100` |
|
||||||
| 105 | kagentz | amdpve | `pct-run 105` |
|
| 105 | kagentz | amdpve | `pct-run 105` |
|
||||||
| 111 | tdunna | storepve | `pct-run 111` (⛔ report-only — no GC) |
|
| 111 | tdunna | storepve | `pct-run 111` (⛔ report-only — no GC) |
|
||||||
| 112 | tanko | amdpve | `pct-run 112` |
|
| 112 | tanko | minipve | `pct-run 112` |
|
||||||
| 113 | baggy | amdpve | `pct-run 113` |
|
| 113 | baggy | amdpve | `pct-run 113` |
|
||||||
| 115 | scottdenya | amdpve | `pct-run 115` |
|
| 115 | scottdenya | amdpve | `pct-run 115` |
|
||||||
| 104 | authentik | minipve | `pct-run 104` |
|
| 104 | authentik | minipve | `pct-run 104` |
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ Before ANY update wave:
|
|||||||
| ocupve (.5) | Proxmox node | `apt update && apt upgrade -y` | 5 min |
|
| ocupve (.5) | Proxmox node | `apt update && apt upgrade -y` | 5 min |
|
||||||
| CT 100 (.24) | Abiba (pi) | `apt update && apt upgrade -y` | 3 min |
|
| CT 100 (.24) | Abiba (pi) | `apt update && apt upgrade -y` | 3 min |
|
||||||
| CT 116 (.116) | syslog-api (LiteLLM host) | `apt update && apt upgrade -y` | 3 min |
|
| CT 116 (.116) | syslog-api (LiteLLM host) | `apt update && apt upgrade -y` | 3 min |
|
||||||
| CT 112 (tanko, amdpve) | Tanko | `apt update && apt upgrade -y` | 3 min |
|
| CT 112 (tanko, minipve) | Tanko | `apt update && apt upgrade -y` | 3 min |
|
||||||
| CT 105 (kagentz, minipve) | Mumuni | `apt update && apt upgrade -y` | 3 min |
|
| CT 105 (kagentz, minipve) | Mumuni | `apt update && apt upgrade -y` | 3 min |
|
||||||
| VM 101 (.8) | llm-gpu (RTX 3090) | `apt update && apt upgrade -y` | 3 min |
|
| VM 101 (.8) | llm-gpu (RTX 3090) | `apt update && apt upgrade -y` | 3 min |
|
||||||
| VM 103 (.110) | ocu-llm (RTX 5070) | `apt update && apt upgrade -y` | 3 min |
|
| VM 103 (.110) | ocu-llm (RTX 5070) | `apt update && apt upgrade -y` | 3 min |
|
||||||
|
|||||||
+44
-6
@@ -6,13 +6,19 @@ name: memory-fixer
|
|||||||
description: >
|
description: >
|
||||||
Auto-fix low-hanging fruit in the RA-H OS knowledge graph. No judgment calls — only deterministic Level 1 operations.
|
Auto-fix low-hanging fruit in the RA-H OS knowledge graph. No judgment calls — only deterministic Level 1 operations.
|
||||||
Escalate anything that needs Kwame's input. Executes confirmed Kwame decisions to completion (state + updated_at).
|
Escalate anything that needs Kwame's input. Executes confirmed Kwame decisions to completion (state + updated_at).
|
||||||
version: 2.1.0
|
version: 2.2.0
|
||||||
---
|
---
|
||||||
---
|
---
|
||||||
|
|
||||||
# Memory Fixer
|
# Memory Fixer
|
||||||
|
|
||||||
> **Canonical copy:** `/root/.hermes/contracts/memory-fixer-v3.md` (used by the `memory-fixer-daily` cron job). This file is the institutional record of the same contract. When the two diverge, treat the v3 source in `/root/.hermes/contracts/` as executable truth.
|
> **Executable copy:** the `okyeame-memory-fixer` cron job on kagentz (`hermes cron list`) holds its instruction
|
||||||
|
> set **inline in `~/.hermes/cron/jobs.json`** (`hermes cron edit <id> --prompt …`; there is no `--prompt-file`, and
|
||||||
|
> `~/.hermes/cron/memory-fixer-prompt.md` is a synced draft, not the live instruction). This file is the institutional
|
||||||
|
> record of the same contract; when the two diverge, the job prompt is what actually runs — diff it against this file
|
||||||
|
> before claiming a prompt change landed.
|
||||||
|
> ⚠️ Corrected 2026-09-26: the previous pointer (`/root/.hermes/contracts/memory-fixer-v3.md`) does not exist on
|
||||||
|
> kagentz — no `/root` access from this container — and was verified unreachable, not merely stale.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
Auto-fix low-hanging fruit in the graph. No judgment calls — only deterministic Level 1 operations. Escalate anything that needs Kwame's input. When Kwame replies to an escalation, **execute the decision to completion** (update state and timestamps), never leaving a node in review-pending forever.
|
Auto-fix low-hanging fruit in the graph. No judgment calls — only deterministic Level 1 operations. Escalate anything that needs Kwame's input. When Kwame replies to an escalation, **execute the decision to completion** (update state and timestamps), never leaving a node in review-pending forever.
|
||||||
@@ -125,15 +131,44 @@ updateNode(id, {
|
|||||||
|
|
||||||
**Archive candidates are identified by the fix 3 query's `suggested_action = 'archive'` branch** (the `ELSE 'archive'` case: anything not an infrastructure/skill/documentation/strategic/audit type).
|
**Archive candidates are identified by the fix 3 query's `suggested_action = 'archive'` branch** (the `ELSE 'archive'` case: anything not an infrastructure/skill/documentation/strategic/audit type).
|
||||||
|
|
||||||
|
### 5. Duplicate-Node Detection (Level 1 — read-only, every run)
|
||||||
|
|
||||||
|
The graph's duplicate problem is rarely an agent mistyping a title: it is **recurring writers creating a new
|
||||||
|
node per run instead of updating one**. This phase detects that class and reports it. It is read-only and
|
||||||
|
**never merges**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 /home/hermes/.hermes/scripts/memory_dup_detect.py --json
|
||||||
|
```
|
||||||
|
Read-only, ~15s over the whole graph, exit 0. That script is the source of truth for the clustering logic —
|
||||||
|
do not re-implement it in the prompt or hand-count "duplicates" from titles.
|
||||||
|
|
||||||
|
Consume each `items[]` entry's `verdict` field; do not invent your own:
|
||||||
|
|
||||||
|
| `verdict` | Meaning | Required action |
|
||||||
|
|---|---|---|
|
||||||
|
| `WRITER-DEFECT` (`run_family: true`) | ONE scheduled task writes a new node per run | Report the ids, the `agents` (the writer) and `span_days`. **Never merge** — each node is that run's audit record. If the family grew since the last report, say `UNFIXED` and name the writer. |
|
||||||
|
| `SAFE-MERGE` | Bodies identical | Still requires an explicit `merge #A into #B` decision from Kwame. |
|
||||||
|
| `HUMAN-DECISION` | Same subject, bodies differ | Propose **connect (an edge)**, never merge. |
|
||||||
|
|
||||||
|
- **Title overlap alone is not duplication.** Four distinct client workflows of one family (#357-#361) and two
|
||||||
|
different machines' migrations (#1792/#1793) both score high on title tokens while their bodies sit 0.1-0.3
|
||||||
|
apart. Confirm against body similarity before calling anything a duplicate.
|
||||||
|
- Report clusters as **candidates for Kwame's decision**, never as established duplicates — a wrong auto-merge
|
||||||
|
destroys distinct content irrecoverably.
|
||||||
|
- Per-run history nodes are kept deliberately. Bulk-merging a run family destroys the audit trail the family exists for.
|
||||||
|
|
||||||
## Level 2 Escalations (Kwame Decision Required)
|
## Level 2 Escalations (Kwame Decision Required)
|
||||||
|
|
||||||
1. **Refresh-suggested stale nodes** flagged with `[REVIEW: refresh]` — refresh or keep? (Archive-suggested nodes are auto-archived under fix 4 and are not escalated.)
|
1. **Refresh-suggested stale nodes** flagged with `[REVIEW: refresh]` — refresh or keep? (Archive-suggested nodes are auto-archived under fix 4 and are not escalated.)
|
||||||
2. **Duplicate Nodes** (same title or >70% title overlap) — Merge or keep?
|
2. **Duplicate Nodes** — as detected by fix 5, by `verdict`, never by raw title overlap. `WRITER-DEFECT` is a writer fix (update one canonical node), not a merge decision; `SAFE-MERGE` and `HUMAN-DECISION` clusters are escalated for merge-or-connect.
|
||||||
3. **Orphan Nodes >90 days old** — Archive or connect?
|
3. **Orphan Nodes >90 days old** — Archive or connect?
|
||||||
|
|
||||||
## Reporting Format
|
## Reporting Format
|
||||||
|
|
||||||
The fixer reports to Kwame via this Zulip DM:
|
The fixer does **not** send anything. Under the single-egress model (2026-09-21) every report leaves the node
|
||||||
|
through Mumuni's gate (`comms_drop.py` for the queue, `comms_gate.py` to release and read-back verify), so
|
||||||
|
exit 0 means QUEUED, never delivered. A report body is written to a file and handed to the outbox helper:
|
||||||
|
|
||||||
```
|
```
|
||||||
🦅 Memory Fixer — [HH:MM UTC]
|
🦅 Memory Fixer — [HH:MM UTC]
|
||||||
@@ -147,8 +182,10 @@ Stale nodes needing review (max 10):
|
|||||||
2. [Node #YYY] Title — Y days stale, SUGGEST: archive
|
2. [Node #YYY] Title — Y days stale, SUGGEST: archive
|
||||||
...
|
...
|
||||||
|
|
||||||
Duplicates needing decision:
|
Duplicate clusters (candidates — Kwame decides; the fixer never merges unilaterally):
|
||||||
1. [Node #AAA] vs [Node #BBB] — Same title
|
1. [WRITER-DEFECT] #AAA/#BBB/#CCC — writer <agent>, N nodes, span Nd (UNFIXED if it grew since the last report)
|
||||||
|
2. [HUMAN-DECISION] #DDD/#EEE — same subject, bodies differ, SUGGEST: connect
|
||||||
|
3. "none" when the scan returned no clusters
|
||||||
|
|
||||||
Orphans >90 days:
|
Orphans >90 days:
|
||||||
1. [Node #EEE] Title — X days stale, orphaned
|
1. [Node #EEE] Title — X days stale, orphaned
|
||||||
@@ -195,6 +232,7 @@ The result must be 0 rows when all decisions are executed. Report what was done.
|
|||||||
- **State integrity:** archived nodes have `state: archived` + `[ARCHIVED]` prefix; kept nodes are `state: active` without a `[REVIEW:]` tag.
|
- **State integrity:** archived nodes have `state: archived` + `[ARCHIVED]` prefix; kept nodes are `state: active` without a `[REVIEW:]` tag.
|
||||||
- **Auto-archive applied:** no node should ever be left tagged `[REVIEW: archive]` — that tag is retired. Any `[REVIEW: archive]` found means fix 4 was skipped; archive it and report.
|
- **Auto-archive applied:** no node should ever be left tagged `[REVIEW: archive]` — that tag is retired. Any `[REVIEW: archive]` found means fix 4 was skipped; archive it and report.
|
||||||
- **No review-pending forever:** after executing Kwame's decisions, `[REVIEW:%` node count must be 0.
|
- **No review-pending forever:** after executing Kwame's decisions, `[REVIEW:%` node count must be 0.
|
||||||
|
- **Duplicate scan ran:** every report carries the fix 5 block (`none` when there were no clusters). A report with no duplicate section means phase 5 was skipped — a silently skipped detection phase is the failure this phase exists to prevent.
|
||||||
- **Timestamps:** every executed decision (and every auto-archive) bumps `updated_at`, so the node exits the stale window on the next run.
|
- **Timestamps:** every executed decision (and every auto-archive) bumps `updated_at`, so the node exits the stale window on the next run.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ Changelog:
|
|||||||
(kagentz CT 105 on minipve, .14, dedicated `hermes` user) and is monitored
|
(kagentz CT 105 on minipve, .14, dedicated `hermes` user) and is monitored
|
||||||
from her side. This script must not probe mumuni or .24 — the v2 changelog
|
from her side. This script must not probe mumuni or .24 — the v2 changelog
|
||||||
roster line was the last reference still placing her at .24 / CT100.
|
roster line was the last reference still placing her at .24 / CT100.
|
||||||
|
v6 (2026-09-28): .8 GPU health probe now runs as `llmuser` instead of `root`.
|
||||||
|
Root SSH to .8 was lost when the guest was rebuilt, so every .8 leg read as
|
||||||
|
UNREACHABLE for a healthy host. llmuser owns llama-server and can read
|
||||||
|
`systemctl is-active`, `systemctl show -p MainPID`, and the :8080 pid.
|
||||||
|
.110 and .15 keep the default `root` user.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess, json, sys, os, time, re, io, contextlib
|
import subprocess, json, sys, os, time, re, io, contextlib
|
||||||
@@ -70,7 +75,7 @@ PVE_NODES = {
|
|||||||
|
|
||||||
# Agent definitions: ct, host, user, pve_node, vault_key_name
|
# Agent definitions: ct, host, user, pve_node, vault_key_name
|
||||||
AGENTS = {
|
AGENTS = {
|
||||||
"tanko": {"ct": 112, "host": "192.168.68.122", "user": "jerome", "pve": "amdpve", "vault_key": "TANKO_LITELLM_API_KEY", "runtime": "dsh"},
|
"tanko": {"ct": 112, "host": "192.168.68.122", "user": "jerome", "pve": "minipve", "vault_key": "TANKO_LITELLM_API_KEY", "runtime": "hybrid"},
|
||||||
# abiba = pi agent (.24) — no vault key; its LiteLLM key is read from its
|
# abiba = pi agent (.24) — no vault key; its LiteLLM key is read from its
|
||||||
# local env file (key_env below), not from the shared vault or .bashrc.
|
# local env file (key_env below), not from the shared vault or .bashrc.
|
||||||
# runtime=pi: abiba has run pi-only since the harness purge. There is no
|
# runtime=pi: abiba has run pi-only since the harness purge. There is no
|
||||||
@@ -95,7 +100,7 @@ AGENTS = {
|
|||||||
# .110 rtx5070 (ocu-llm VM) -> llama-server.service (active)
|
# .110 rtx5070 (ocu-llm VM) -> llama-server.service (active)
|
||||||
# .15 strixhalo (amdpve) -> strix-server.service (active)
|
# .15 strixhalo (amdpve) -> strix-server.service (active)
|
||||||
GPU_HOSTS = {
|
GPU_HOSTS = {
|
||||||
"gpu-rtx3090 (.8)": {"host": "192.168.68.8", "port": 8080, "service": "llama-chat-api.service"},
|
"gpu-rtx3090 (.8)": {"host": "192.168.68.8", "port": 8080, "service": "llama-chat-api.service", "user": "llmuser"},
|
||||||
"gpu-rtx5070 (.110)": {"host": "192.168.68.110", "port": 8080, "service": "llama-server.service"},
|
"gpu-rtx5070 (.110)": {"host": "192.168.68.110", "port": 8080, "service": "llama-server.service"},
|
||||||
"gpu-strixhalo (.15)": {"host": "192.168.68.15", "port": 8080, "service": "strix-server.service"},
|
"gpu-strixhalo (.15)": {"host": "192.168.68.15", "port": 8080, "service": "strix-server.service"},
|
||||||
}
|
}
|
||||||
@@ -147,6 +152,18 @@ def ssh(host, cmd, user="root"):
|
|||||||
except:
|
except:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_user_home(user):
|
||||||
|
"""Resolve the home directory for a user.
|
||||||
|
|
||||||
|
For 'root', returns '/root'. For any other user, returns '/home/<user>'.
|
||||||
|
This is used to construct paths that reference a user's home directory
|
||||||
|
(e.g., ~/.local/bin/hermes, ~/.hermes/config.yaml) instead of hardcoding /root/.
|
||||||
|
"""
|
||||||
|
if user == "root":
|
||||||
|
return "/root"
|
||||||
|
else:
|
||||||
|
return f"/home/{user}"
|
||||||
|
|
||||||
def http_get(url, headers=None, timeout=5):
|
def http_get(url, headers=None, timeout=5):
|
||||||
"""Return HTTP status code as string."""
|
"""Return HTTP status code as string."""
|
||||||
try:
|
try:
|
||||||
@@ -300,13 +317,14 @@ def check_gpu_ports():
|
|||||||
host = gpu["host"]
|
host = gpu["host"]
|
||||||
port = gpu["port"]
|
port = gpu["port"]
|
||||||
svc = gpu["service"]
|
svc = gpu["service"]
|
||||||
|
user = gpu.get("user", "root") # default root, overridden per-host where needed
|
||||||
|
|
||||||
# `systemctl is-active` exits non-zero when the unit is inactive or
|
# `systemctl is-active` exits non-zero when the unit is inactive or
|
||||||
# missing, which the ssh() helper would swallow as an SSH failure and
|
# missing, which the ssh() helper would swallow as an SSH failure and
|
||||||
# report as UNREACHABLE. `|| true` keeps the real state word so we can
|
# report as UNREACHABLE. `|| true` keeps the real state word so we can
|
||||||
# tell "unit inactive" from "host unreachable".
|
# tell "unit inactive" from "host unreachable".
|
||||||
svc_status = ssh(host, f"systemctl is-active {svc} || true")
|
svc_status = ssh(host, f"systemctl is-active {svc} || true", user=user)
|
||||||
port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1")
|
port_owner = ssh(host, f"ss -tlnp 2>/dev/null | grep -Po ':{port}\\s+.*pid=\\K[0-9]+' | head -1", user=user)
|
||||||
|
|
||||||
if not svc_status:
|
if not svc_status:
|
||||||
print(f" ❌ {label}: UNREACHABLE")
|
print(f" ❌ {label}: UNREACHABLE")
|
||||||
@@ -317,14 +335,14 @@ def check_gpu_ports():
|
|||||||
print(f" ❌ {label}: PORT {port} NOT LISTENING (svc={svc_status})")
|
print(f" ❌ {label}: PORT {port} NOT LISTENING (svc={svc_status})")
|
||||||
FAIL.append(f"gpu-no-port:{label}")
|
FAIL.append(f"gpu-no-port:{label}")
|
||||||
elif svc_status != "active":
|
elif svc_status != "active":
|
||||||
svc_pid = ssh(host, f"systemctl show {svc} -p MainPID 2>/dev/null | cut -d= -f2")
|
svc_pid = ssh(host, f"systemctl show {svc} -p MainPID 2>/dev/null | cut -d= -f2", user=user)
|
||||||
if svc_pid and port_owner != svc_pid:
|
if svc_pid and port_owner != svc_pid:
|
||||||
print(f" ❌ {label}: GHOST PROCESS — port owned by pid {port_owner}, svc pid {svc_pid} (svc={svc_status})")
|
print(f" ❌ {label}: GHOST PROCESS — port owned by pid {port_owner}, svc pid {svc_pid} (svc={svc_status})")
|
||||||
FAIL.append(f"gpu-ghost:{label}:{port_owner}")
|
FAIL.append(f"gpu-ghost:{label}:{port_owner}")
|
||||||
else:
|
else:
|
||||||
print(f" ⚠️ {label}: svc={svc_status}, port owned by {port_owner}")
|
print(f" ⚠️ {label}: svc={svc_status}, port owned by {port_owner}")
|
||||||
else:
|
else:
|
||||||
health = ssh(host, f"curl -s --max-time 5 http://localhost:{port}/health")
|
health = ssh(host, f"curl -s --max-time 5 http://localhost:{port}/health", user=user)
|
||||||
if health and '"status":"ok"' in health:
|
if health and '"status":"ok"' in health:
|
||||||
print(f" ✅ {label}: healthy (pid={port_owner})")
|
print(f" ✅ {label}: healthy (pid={port_owner})")
|
||||||
elif health and '"status":"no slot available"' in health:
|
elif health and '"status":"no slot available"' in health:
|
||||||
@@ -376,10 +394,10 @@ def check_agents():
|
|||||||
ct = agent["ct"]
|
ct = agent["ct"]
|
||||||
report_only = agent.get("report_only", False)
|
report_only = agent.get("report_only", False)
|
||||||
|
|
||||||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — it no longer runs a
|
# Tanko runs hybrid (DSH + Hermes) since 2026-08-27 — it runs both DSH and Hermes gateway.
|
||||||
# Hermes gateway, so skip the Hermes gateway/state/streaming/journal checks.
|
# Hermes gateway, so skip the Hermes gateway/state/streaming/journal checks.
|
||||||
# Non-Hermes runtimes have no gateway to probe. dsh = Tanko since
|
# Non-Hermes runtimes have no gateway to probe. dsh/pi-only skip the check;
|
||||||
# 2026-08-27; pi = abiba since the harness purge (.24 is pi-only).
|
# hybrid runs both DSH and Hermes and is checked normally.
|
||||||
if agent.get("runtime") in ("dsh", "pi"):
|
if agent.get("runtime") in ("dsh", "pi"):
|
||||||
is_dsh = agent.get("runtime") == "dsh"
|
is_dsh = agent.get("runtime") == "dsh"
|
||||||
label = "DSH (DeepSeek Harness)" if is_dsh else "pi-only runtime"
|
label = "DSH (DeepSeek Harness)" if is_dsh else "pi-only runtime"
|
||||||
@@ -500,7 +518,7 @@ def check_ct_liveness():
|
|||||||
def check_config_integrity():
|
def check_config_integrity():
|
||||||
"""Verify agent config.yaml parses as valid YAML."""
|
"""Verify agent config.yaml parses as valid YAML."""
|
||||||
for name, agent in AGENTS.items():
|
for name, agent in AGENTS.items():
|
||||||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no Hermes config.yaml.
|
# DSH/pi-only runtimes have no Hermes config.yaml; hybrid has both.
|
||||||
if agent.get("runtime") == "dsh":
|
if agent.get("runtime") == "dsh":
|
||||||
print(f" ⏭️ {name}: DSH — no Hermes config.yaml since 2026-08-27")
|
print(f" ⏭️ {name}: DSH — no Hermes config.yaml since 2026-08-27")
|
||||||
continue
|
continue
|
||||||
@@ -513,11 +531,11 @@ def check_config_integrity():
|
|||||||
print(f" ⬜ {name}: cannot SSH — skip config check")
|
print(f" ⬜ {name}: cannot SSH — skip config check")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
home = get_user_home(user)
|
||||||
|
|
||||||
# Check YAML parses
|
# Check YAML parses
|
||||||
yaml_ok = ssh(host,
|
yaml_ok = ssh(host,
|
||||||
"python3 -c "
|
f"python3 -c \"import yaml; yaml.safe_load(open('{home}/.hermes/config.yaml')); print('OK')\" 2>&1 || echo 'FAIL'",
|
||||||
'"import yaml; yaml.safe_load(open(\'/root/.hermes/config.yaml\')); print(\'OK\')" '
|
|
||||||
"2>&1 || echo 'FAIL'",
|
|
||||||
user=user)
|
user=user)
|
||||||
if not yaml_ok:
|
if not yaml_ok:
|
||||||
print(f" ❌ {name}: SSH UNREACHABLE (config check skipped)")
|
print(f" ❌ {name}: SSH UNREACHABLE (config check skipped)")
|
||||||
@@ -556,7 +574,7 @@ def _infisical_invocation_paths(wrapper_body):
|
|||||||
def check_wrapper_integrity():
|
def check_wrapper_integrity():
|
||||||
"""Verify the hermes CLI wrapper exists and can reach hermes-real."""
|
"""Verify the hermes CLI wrapper exists and can reach hermes-real."""
|
||||||
for name, agent in AGENTS.items():
|
for name, agent in AGENTS.items():
|
||||||
# Tanko runs on DSH (DeepSeek Harness) since 2026-08-27 — no hermes CLI wrapper.
|
# DSH/pi-only runtimes have no hermes CLI wrapper; hybrid has both.
|
||||||
if agent.get("runtime") == "dsh":
|
if agent.get("runtime") == "dsh":
|
||||||
print(f" ⏭️ {name}: DSH — no hermes CLI wrapper since 2026-08-27")
|
print(f" ⏭️ {name}: DSH — no hermes CLI wrapper since 2026-08-27")
|
||||||
continue
|
continue
|
||||||
@@ -570,7 +588,8 @@ def check_wrapper_integrity():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Check wrapper exists
|
# Check wrapper exists
|
||||||
wrapper = ssh(host, "ls -la /root/.local/bin/hermes 2>/dev/null", user=user)
|
home = get_user_home(user)
|
||||||
|
wrapper = ssh(host, f"ls -la {home}/.local/bin/hermes 2>/dev/null", user=user)
|
||||||
if not wrapper:
|
if not wrapper:
|
||||||
# Check alternate wrapper locations
|
# Check alternate wrapper locations
|
||||||
wrapper = ssh(host, "which hermes 2>/dev/null; command -v hermes 2>/dev/null", user=user)
|
wrapper = ssh(host, "which hermes 2>/dev/null; command -v hermes 2>/dev/null", user=user)
|
||||||
@@ -593,7 +612,7 @@ def check_wrapper_integrity():
|
|||||||
# a removed path (litellm-api-keys.prose.md documents
|
# a removed path (litellm-api-keys.prose.md documents
|
||||||
# `rm -f /usr/local/bin/infisical`) must neither produce a dangling path
|
# `rm -f /usr/local/bin/infisical`) must neither produce a dangling path
|
||||||
# nor trigger the PATH check — it is not an invocation.
|
# nor trigger the PATH check — it is not an invocation.
|
||||||
wrapper_body = ssh(host, "cat /root/.local/bin/hermes 2>/dev/null", user=user) or ""
|
wrapper_body = ssh(host, f"cat {home}/.local/bin/hermes 2>/dev/null", user=user) or ""
|
||||||
wrapper_code = "\n".join(line.split("#", 1)[0] for line in wrapper_body.splitlines())
|
wrapper_code = "\n".join(line.split("#", 1)[0] for line in wrapper_body.splitlines())
|
||||||
invoked_paths = _infisical_invocation_paths(wrapper_body)
|
invoked_paths = _infisical_invocation_paths(wrapper_body)
|
||||||
if "infisical" in wrapper_code:
|
if "infisical" in wrapper_code:
|
||||||
@@ -627,24 +646,35 @@ def check_wrapper_integrity():
|
|||||||
else:
|
else:
|
||||||
print(f" ℹ️ {name}: wrapper resolves creds without infisical (e.g. ~/.hermes/.env) — OK")
|
print(f" ℹ️ {name}: wrapper resolves creds without infisical (e.g. ~/.hermes/.env) — OK")
|
||||||
|
|
||||||
# Check hermes-real exists
|
# Check that the wrapper's target resolves. The fleet's wrappers do NOT
|
||||||
|
# all use a hermes-real indirection — some exec the venv module directly.
|
||||||
|
# Verify the wrapper actually points to something runnable.
|
||||||
hermes_real = ssh(host,
|
hermes_real = ssh(host,
|
||||||
"ls -la /root/.local/bin/hermes-real 2>/dev/null || echo MISS",
|
f"ls -la {home}/.local/bin/hermes-real 2>/dev/null || echo MISS",
|
||||||
user=user)
|
user=user)
|
||||||
if not hermes_real or hermes_real.strip() == "MISS":
|
if hermes_real and hermes_real.strip() != "MISS":
|
||||||
# Check venv path
|
print(f" ✅ {name}: wrapper shape: hermes-real at {home}/.local/bin/hermes-real")
|
||||||
hermes_real = ssh(host,
|
else:
|
||||||
"ls -la /usr/local/lib/hermes-agent/venv/bin/hermes 2>/dev/null || echo MISS",
|
# Try the venv under home
|
||||||
|
venv_home = ssh(host,
|
||||||
|
f"test -x {home}/.hermes/hermes-agent/venv/bin/python && echo OK || echo MISS",
|
||||||
user=user)
|
user=user)
|
||||||
if not hermes_real or hermes_real.strip() == "MISS":
|
if venv_home and venv_home.strip().splitlines()[-1] == "OK":
|
||||||
print(f" ❌ {name}: hermes-real NOT FOUND (wrapper broken)")
|
print(f" ✅ {name}: wrapper shape: direct venv exec ({home}/.hermes/hermes-agent/venv/bin/python)")
|
||||||
_fail(f"wrapper-no-hermes-real:{name}", name)
|
|
||||||
else:
|
else:
|
||||||
print(f" ✅ {name}: hermes-real at alt path")
|
# Try the system-wide venv
|
||||||
|
venv_sys = ssh(host,
|
||||||
|
"test -x /usr/local/lib/hermes-agent/venv/bin/python && echo OK || echo MISS",
|
||||||
|
user=user)
|
||||||
|
if venv_sys and venv_sys.strip().splitlines()[-1] == "OK":
|
||||||
|
print(f" ✅ {name}: wrapper shape: system venv (/usr/local/lib/hermes-agent/venv/bin/python)")
|
||||||
|
else:
|
||||||
|
print(f" ❌ {name}: wrapper target NOT RESOLVABLE (no hermes-real, no venv)")
|
||||||
|
_fail(f"wrapper-no-hermes-real:{name}", name)
|
||||||
|
|
||||||
# Check the .env file has the key
|
# Check the .env file has the key
|
||||||
env_has_key = ssh(host,
|
env_has_key = ssh(host,
|
||||||
"grep -c 'LITELLM_API_KEY' /root/.hermes/.env 2>/dev/null || echo 0",
|
f"grep -c 'LITELLM_API_KEY' {home}/.hermes/.env 2>/dev/null || echo 0",
|
||||||
user=user)
|
user=user)
|
||||||
if env_has_key and env_has_key.strip() not in ("", "0"):
|
if env_has_key and env_has_key.strip() not in ("", "0"):
|
||||||
print(f" ✅ {name}: wrapper + .env key present")
|
print(f" ✅ {name}: wrapper + .env key present")
|
||||||
|
|||||||
+169
-38
@@ -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
|
def zulip_key():
|
||||||
msg["Subject"] = SUBJECT
|
"""abiba-bot's Zulip key, from the env or the on-host 600 file."""
|
||||||
msg.attach(MIMEText("Infrastructure report in HTML format — enable images to view.", "plain"))
|
key = os.environ.get("ABIBA_ZULIP_API_KEY")
|
||||||
msg.attach(MIMEText(html_content, "html"))
|
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()
|
|
||||||
server.login(GMAIL_EMAIL, EMAIL_PASSWORD)
|
def build_summary(r, filename, test=False):
|
||||||
server.sendmail(FROM, [TO], msg.as_string())
|
"""Short Markdown body: subject, top-line status, pointer to the attachment.
|
||||||
server.quit()
|
|
||||||
return True, "✅ Email sent to jerome@sysloggh.com"
|
Deliberately NOT a reproduction of the report - the attachment is the report.
|
||||||
except Exception as e:
|
"""
|
||||||
return False, f"❌ Email failed: {e}"
|
nodes = f"{r.get('nodes_online', 0)}/{r.get('node_count', 0)} nodes online"
|
||||||
|
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_zulip(html, report, test=is_test)
|
||||||
ok, msg = send_email(html, subject_prefix=prefix)
|
|
||||||
print(f" {msg}")
|
print(f" {msg}")
|
||||||
|
|
||||||
# Show summary
|
# Show summary
|
||||||
|
|||||||
@@ -101,8 +101,6 @@ GUESTS: list[Guest] = [
|
|||||||
# amdpve (192.168.68.15)
|
# amdpve (192.168.68.15)
|
||||||
Guest(ct_id="105", hostname="kagentz", ip="192.168.68.105", node="amdpve",
|
Guest(ct_id="105", hostname="kagentz", ip="192.168.68.105", node="amdpve",
|
||||||
access_method="ssh-host", probe_target="kagentz (CT 105, amdpve)"),
|
access_method="ssh-host", probe_target="kagentz (CT 105, amdpve)"),
|
||||||
Guest(ct_id="112", hostname="tanko", ip="192.168.68.112", node="amdpve",
|
|
||||||
access_method="pct-run", probe_target="tanko (CT 112, amdpve)"),
|
|
||||||
Guest(ct_id="113", hostname="baggy", ip="192.168.68.113", node="amdpve",
|
Guest(ct_id="113", hostname="baggy", ip="192.168.68.113", node="amdpve",
|
||||||
access_method="pct-run", probe_target="baggy (CT 113, amdpve)"),
|
access_method="pct-run", probe_target="baggy (CT 113, amdpve)"),
|
||||||
Guest(ct_id="115", hostname="scottdenya", ip="192.168.68.115", node="amdpve",
|
Guest(ct_id="115", hostname="scottdenya", ip="192.168.68.115", node="amdpve",
|
||||||
@@ -110,6 +108,8 @@ GUESTS: list[Guest] = [
|
|||||||
Guest(ct_id="120", hostname="adguard2", ip="192.168.68.120", node="amdpve",
|
Guest(ct_id="120", hostname="adguard2", ip="192.168.68.120", node="amdpve",
|
||||||
access_method="pct-run", probe_target="adguard2 (CT 120, amdpve)"),
|
access_method="pct-run", probe_target="adguard2 (CT 120, amdpve)"),
|
||||||
# minipve (192.168.68.12)
|
# minipve (192.168.68.12)
|
||||||
|
Guest(ct_id="112", hostname="tanko", ip="192.168.68.112", node="minipve",
|
||||||
|
access_method="pct-run", probe_target="tanko (CT 112, minipve)"),
|
||||||
Guest(ct_id="100", hostname="abiba", ip="192.168.68.100", node="minipve",
|
Guest(ct_id="100", hostname="abiba", ip="192.168.68.100", node="minipve",
|
||||||
access_method="pct-run", probe_target="abiba (CT 100, minipve)"),
|
access_method="pct-run", probe_target="abiba (CT 100, minipve)"),
|
||||||
Guest(ct_id="102", hostname="adguard", ip="192.168.68.102", node="minipve",
|
Guest(ct_id="102", hostname="adguard", ip="192.168.68.102", node="minipve",
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,6 @@ set -euo pipefail
|
|||||||
declare -A CT_NODES=(
|
declare -A CT_NODES=(
|
||||||
# amdpve (192.168.68.15)
|
# amdpve (192.168.68.15)
|
||||||
[105]=amdpve # kagentz (was hwepve — corrected 2026-09-12; live per pvesh)
|
[105]=amdpve # kagentz (was hwepve — corrected 2026-09-12; live per pvesh)
|
||||||
[112]=amdpve # tanko
|
|
||||||
[113]=amdpve # baggy
|
[113]=amdpve # baggy
|
||||||
[115]=amdpve # scottdenya
|
[115]=amdpve # scottdenya
|
||||||
[120]=amdpve # adguard2 (added 2026-09-12)
|
[120]=amdpve # adguard2 (added 2026-09-12)
|
||||||
@@ -21,6 +20,7 @@ declare -A CT_NODES=(
|
|||||||
[102]=minipve # adguard (was acerpve)
|
[102]=minipve # adguard (was acerpve)
|
||||||
[104]=minipve # authentik
|
[104]=minipve # authentik
|
||||||
[110]=minipve # gitea
|
[110]=minipve # gitea
|
||||||
|
[112]=minipve # tanko (was amdpve — migrated 2026-09-27; live per pvesh)
|
||||||
[116]=minipve # syslog-api
|
[116]=minipve # syslog-api
|
||||||
[119]=minipve # infisical-vault
|
[119]=minipve # infisical-vault
|
||||||
# storepve (192.168.68.6)
|
# storepve (192.168.68.6)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ You are a code reviewer for OpenProse infrastructure contracts in the Syslog Sol
|
|||||||
The infrastructure-control.prose.md contract is the canonical reference for the cluster topology:
|
The infrastructure-control.prose.md contract is the canonical reference for the cluster topology:
|
||||||
|
|
||||||
**Proxmox Cluster "Tabiri" (5 nodes):**
|
**Proxmox Cluster "Tabiri" (5 nodes):**
|
||||||
- amdpve (192.168.68.15): kagentz, tanko, baggy, scottdenya, adguard2
|
- amdpve (192.168.68.15): kagentz, baggy, scottdenya, adguard2
|
||||||
- minipve (192.168.68.12): abiba, adguard, authentik, gitea, syslog-api, infisical-vault
|
- minipve (192.168.68.12): abiba, tanko, adguard, authentik, gitea, syslog-api, infisical-vault
|
||||||
- storepve (192.168.68.6): docker-vm, ra-h-os, PBS, media, jdownloader, zulip, tdunna
|
- storepve (192.168.68.6): docker-vm, ra-h-os, PBS, media, jdownloader, zulip, tdunna
|
||||||
- acerpve (192.168.68.9): llm-gpu
|
- acerpve (192.168.68.9): llm-gpu
|
||||||
- ocupve (192.168.68.5): ocu-llm
|
- ocupve (192.168.68.5): ocu-llm
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# swap-gpu-dense-model.sh — Swap RTX 3090 from qwen3.6-27B-code to SmartCode-Fable-5
|
# swap-gpu-dense-model.sh — Swap RTX 3090 from qwen3.6-27B-code to SmartCode-Fable-5
|
||||||
# Run when download completes: ssh root@192.168.68.8 'bash -s' < this script
|
# Run when download completes: ssh llmuser@192.168.68.8 'sudo bash -s' < this script
|
||||||
#
|
#
|
||||||
# Usage: bash swap-gpu-dense-model.sh
|
# Usage: bash swap-gpu-dense-model.sh
|
||||||
# Requires: new model at /home/llmuser/models/SmartCode-Fable-5-27B-UD-Q4_K_XL.gguf
|
# Requires: new model at /home/llmuser/models/SmartCode-Fable-5-27B-UD-Q4_K_XL.gguf
|
||||||
|
|||||||
@@ -135,16 +135,16 @@ case "$PI_VERDICT" in
|
|||||||
esac
|
esac
|
||||||
# -- abiba-leg-end
|
# -- abiba-leg-end
|
||||||
|
|
||||||
# ── Platform B: Tanko (DSH dsh-web on amdpve CT 112) ──
|
# ── Platform B: Tanko (DSH dsh-web on minipve CT 112) ──
|
||||||
# Direct SSH to 192.168.68.122 is not a dependency of this monitor — per-worker
|
# Direct SSH to 192.168.68.122 is not a dependency of this monitor — per-worker
|
||||||
# key availability varies — so probes run from the amdpve vantage via `pct exec`.
|
# key availability varies — so probes run from the minipve vantage via `pct exec`.
|
||||||
# Tanko's Zulip gateway runs as the dsh-web systemd unit inside CT 112 on amdpve
|
# Tanko's Zulip gateway runs as the dsh-web systemd unit inside CT 112 on minipve
|
||||||
# (192.168.68.15). The gateway binds 127.0.0.1:3080 loopback-only by design — a
|
# (192.168.68.12). The gateway binds 127.0.0.1:3080 loopback-only by design — a
|
||||||
# remote :3080 probe is refused and is NOT a fault.
|
# remote :3080 probe is refused and is NOT a fault.
|
||||||
TANKO_SVC=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.15 \
|
TANKO_SVC=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.12 \
|
||||||
"pct exec 112 -- systemctl is-active dsh-web" 2>/dev/null || true)
|
"pct exec 112 -- systemctl is-active dsh-web" 2>/dev/null || true)
|
||||||
[ -n "$TANKO_SVC" ] || TANKO_SVC="unknown"
|
[ -n "$TANKO_SVC" ] || TANKO_SVC="unknown"
|
||||||
TANKO_HTTP=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.15 \
|
TANKO_HTTP=$(ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 root@192.168.68.12 \
|
||||||
"pct exec 112 -- curl -s --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/" 2>/dev/null || true)
|
"pct exec 112 -- curl -s --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/" 2>/dev/null || true)
|
||||||
[ -n "$TANKO_HTTP" ] || TANKO_HTTP="000"
|
[ -n "$TANKO_HTTP" ] || TANKO_HTTP="000"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
"""Regression test for the fallback_providers list-shape crash in audit-hermes-config.py.
|
||||||
|
|
||||||
|
WHY THIS FILE EXISTS: audit-hermes-config.py assumed `fallback_providers` was always a dict
|
||||||
|
(single provider). Two live agents (koby, koonimo) carry it as a LIST of dicts (one entry per
|
||||||
|
fallback), so the script crashed with:
|
||||||
|
|
||||||
|
File "audit-hermes-config.py", line 211, in audit
|
||||||
|
fb.get("provider") == "deepseek",
|
||||||
|
AttributeError: 'list' object has no attribute 'get'
|
||||||
|
|
||||||
|
Both are REAL agent configs, so this is not a malformed-input case — the script simply could not
|
||||||
|
audit two of the four agents it exists to audit. Until fixed, the key-hygiene check had no
|
||||||
|
coverage for half the fleet while appearing to run.
|
||||||
|
|
||||||
|
These tests execute the real CLI (`python3 audit-hermes-config.py <config>`) and assert:
|
||||||
|
1. A config whose `fallback_providers` is a LIST of valid dicts does NOT crash (exit code is 0 or 1,
|
||||||
|
never a traceback/AttributeError).
|
||||||
|
2. A config whose `fallback_providers` contains a MALFORMED entry (a list element that is not a
|
||||||
|
mapping) reports a VIOLATION naming the offending entry, NOT an uncaught exception.
|
||||||
|
3. The dict shape still works (existing tests must stay green).
|
||||||
|
|
||||||
|
No network, vault, or SSH access is required.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
AUDIT = ROOT / "audit-hermes-config.py"
|
||||||
|
|
||||||
|
# A valid config where fallback_providers is a LIST of dicts (the real koby/koonimo shape).
|
||||||
|
# One entry, well-formed: provider=deepseek, model=deepseek-v4-flash, api_key_env=DEEPSEEK_API_KEY.
|
||||||
|
# This must produce a real verdict (PASS or FAIL) without crashing.
|
||||||
|
LIST_SHAPE_VALID = """
|
||||||
|
model:
|
||||||
|
api_key: ""
|
||||||
|
api_key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
max_tokens: 4096
|
||||||
|
default: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
fallback_providers:
|
||||||
|
- provider: deepseek
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
threshold: 0.65
|
||||||
|
max_context_window: 131072
|
||||||
|
auxiliary:
|
||||||
|
vision:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
web_extract:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
delegation:
|
||||||
|
provider: harness
|
||||||
|
custom_providers:
|
||||||
|
- name: harness
|
||||||
|
key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A valid config where fallback_providers is a LIST with TWO entries (multiple fallbacks).
|
||||||
|
# Both entries well-formed. Must not crash and should produce a real verdict.
|
||||||
|
LIST_SHAPE_MULTI = """
|
||||||
|
model:
|
||||||
|
api_key: ""
|
||||||
|
api_key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
max_tokens: 4096
|
||||||
|
default: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
fallback_providers:
|
||||||
|
- provider: deepseek
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
- provider: deepseek
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
threshold: 0.65
|
||||||
|
max_context_window: 131072
|
||||||
|
auxiliary:
|
||||||
|
vision:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
web_extract:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
delegation:
|
||||||
|
provider: harness
|
||||||
|
custom_providers:
|
||||||
|
- name: harness
|
||||||
|
key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
# A config where fallback_providers is a LIST containing a MALFORMED entry:
|
||||||
|
# one element is a plain string, not a mapping. The checker must report a VIOLATION
|
||||||
|
# naming the offending entry (fallback_providers[1]) and NOT crash.
|
||||||
|
LIST_SHAPE_MALFORMED = """
|
||||||
|
model:
|
||||||
|
api_key: ""
|
||||||
|
api_key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
max_tokens: 4096
|
||||||
|
default: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
fallback_providers:
|
||||||
|
- provider: deepseek
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
- "not-a-mapping"
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
threshold: 0.65
|
||||||
|
max_context_window: 131072
|
||||||
|
auxiliary:
|
||||||
|
vision:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
web_extract:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
delegation:
|
||||||
|
provider: harness
|
||||||
|
custom_providers:
|
||||||
|
- name: harness
|
||||||
|
key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
# The original DICT shape (single provider) must still work — existing behaviour preserved.
|
||||||
|
DICT_SHAPE_VALID = """
|
||||||
|
model:
|
||||||
|
api_key: ""
|
||||||
|
api_key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
max_tokens: 4096
|
||||||
|
default: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
fallback_providers:
|
||||||
|
provider: deepseek
|
||||||
|
model: deepseek-v4-flash
|
||||||
|
api_key_env: DEEPSEEK_API_KEY
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
threshold: 0.65
|
||||||
|
max_context_window: 131072
|
||||||
|
auxiliary:
|
||||||
|
vision:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
web_extract:
|
||||||
|
model: gpu-vision
|
||||||
|
provider: harness
|
||||||
|
compression:
|
||||||
|
model: syslog-auto
|
||||||
|
provider: harness
|
||||||
|
delegation:
|
||||||
|
provider: harness
|
||||||
|
custom_providers:
|
||||||
|
- name: harness
|
||||||
|
key_env: LITELLM_API_KEY
|
||||||
|
base_url: http://192.168.68.116/litellm/v1
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _run_config(tmp_path, name, text):
|
||||||
|
cfg = tmp_path / name
|
||||||
|
cfg.write_text(text)
|
||||||
|
proc = subprocess.run(
|
||||||
|
[sys.executable, str(AUDIT), str(cfg)],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shape_single_entry_does_not_crash(tmp_path):
|
||||||
|
"""A LIST with one valid dict must not raise AttributeError; exit 0 (PASS)."""
|
||||||
|
code, out, err = _run_config(tmp_path, "list-single.yaml", LIST_SHAPE_VALID)
|
||||||
|
# Must NOT be a crash (traceback). A clean run exits 0 (PASS) or 1 (FAIL), never 2+ (exception).
|
||||||
|
assert code in (0, 1), f"Expected clean exit 0 or 1, got {code}\nSTDOUT:\n{out}\nSTDERR:\n{err}"
|
||||||
|
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
|
||||||
|
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
|
||||||
|
# The valid single-entry list should PASS (all rules satisfied).
|
||||||
|
assert code == 0, f"Expected PASS but got {code}\n{out}"
|
||||||
|
assert "RESULT: PASS" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shape_multiple_entries_does_not_crash(tmp_path):
|
||||||
|
"""A LIST with two valid dicts must not raise AttributeError; exit 0 (PASS)."""
|
||||||
|
code, out, err = _run_config(tmp_path, "list-multi.yaml", LIST_SHAPE_MULTI)
|
||||||
|
assert code in (0, 1), f"Expected clean exit 0 or 1, got {code}\nSTDOUT:\n{out}\nSTDERR:\n{err}"
|
||||||
|
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
|
||||||
|
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
|
||||||
|
assert code == 0, f"Expected PASS but got {code}\n{out}"
|
||||||
|
assert "RESULT: PASS" in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_shape_malformed_entry_reports_violation_not_crash(tmp_path):
|
||||||
|
"""A LIST containing a non-mapping element must be a reported VIOLATION, not a crash."""
|
||||||
|
code, out, err = _run_config(tmp_path, "list-malformed.yaml", LIST_SHAPE_MALFORMED)
|
||||||
|
# Must NOT be a crash.
|
||||||
|
assert "AttributeError" not in err, f"Crashed with AttributeError:\n{err}"
|
||||||
|
assert "Traceback" not in err, f"Crashed with uncaught exception:\n{err}"
|
||||||
|
# Should be a FAIL (exit 1) because the malformed entry is a violation.
|
||||||
|
assert code == 1, f"Expected FAIL (exit 1) but got {code}\n{out}"
|
||||||
|
assert "RESULT: FAIL" in out
|
||||||
|
# The violation must name the offending entry (fallback_providers[1]).
|
||||||
|
assert "fallback_providers[1]" in out, f"Violation did not name the offending entry:\n{out}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dict_shape_still_passes(tmp_path):
|
||||||
|
"""The original DICT shape (single provider) must still PASS — existing behaviour preserved."""
|
||||||
|
code, out, err = _run_config(tmp_path, "dict-valid.yaml", DICT_SHAPE_VALID)
|
||||||
|
assert code == 0, f"Expected PASS but got {code}\n{out}\nSTDERR:\n{err}"
|
||||||
|
assert "RESULT: PASS" in out
|
||||||
@@ -49,7 +49,7 @@ HEALTH_CONTRACT = ROOT / "zulip-health.prose.md"
|
|||||||
CONNECTED_FIXTURE = ROOT / "tests" / "fixtures" / "zulip-health-connected.json"
|
CONNECTED_FIXTURE = ROOT / "tests" / "fixtures" / "zulip-health-connected.json"
|
||||||
|
|
||||||
MUMUNI_IP = "192.168.68.24" # Mumuni's old (decommissioned) deployment
|
MUMUNI_IP = "192.168.68.24" # Mumuni's old (decommissioned) deployment
|
||||||
TANKO_VANTAGE = "192.168.68.15" # amdpve — Tanko CT 112 via pct exec
|
TANKO_VANTAGE = "192.168.68.12" # minipve — Tanko CT 112 via pct exec
|
||||||
AGENT_ZERO_HOST = "192.168.68.14" # kagentz host, Agent Zero docker
|
AGENT_ZERO_HOST = "192.168.68.14" # kagentz host, Agent Zero docker
|
||||||
|
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ done
|
|||||||
printf '%s\n' "$host" >> "$RECORD_DIR/ssh.hosts"
|
printf '%s\n' "$host" >> "$RECORD_DIR/ssh.hosts"
|
||||||
cmd="${*: -1}"
|
cmd="${*: -1}"
|
||||||
case "$host" in
|
case "$host" in
|
||||||
192.168.68.15)
|
192.168.68.12)
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
*"systemctl is-active"*) printf '%s' "$TANKO_SVC" ;;
|
*"systemctl is-active"*) printf '%s' "$TANKO_SVC" ;;
|
||||||
*curl*) printf '%s' "$TANKO_HTTP" ;;
|
*curl*) printf '%s' "$TANKO_HTTP" ;;
|
||||||
|
|||||||
@@ -104,6 +104,59 @@ def test_koby_ct111_is_on_storepve(ahc):
|
|||||||
assert ahc.AGENTS["koby"]["pve"] == "storepve"
|
assert ahc.AGENTS["koby"]["pve"] == "storepve"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tanko_ct112_is_probed_on_minipve(ahc, monkeypatch, capsys):
|
||||||
|
# CT 112 (tanko) was live-migrated to minipve (.12) on 2026-09-27; the
|
||||||
|
# amdpve mapping made `pct status 112` fail and read as ct-unreachable.
|
||||||
|
# Execute the probe and assert the host the script actually contacts.
|
||||||
|
probes = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ahc, "ssh",
|
||||||
|
lambda host, cmd, user="root": probes.append((host, cmd)) or "status: running",
|
||||||
|
)
|
||||||
|
ahc.FAIL.clear()
|
||||||
|
ahc.REPORT_ONLY.clear()
|
||||||
|
try:
|
||||||
|
ahc.check_ct_liveness()
|
||||||
|
tanko_hosts = [h for h, cmd in probes if cmd == "pct status 112 2>/dev/null"]
|
||||||
|
assert tanko_hosts == ["192.168.68.12"]
|
||||||
|
finally:
|
||||||
|
ahc.FAIL.clear()
|
||||||
|
ahc.REPORT_ONLY.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_rtx3090_probe_uses_llmuser_not_root(ahc, monkeypatch, capsys):
|
||||||
|
# 2026-09-28: root SSH to .8 was lost when the guest was rebuilt; llmuser
|
||||||
|
# owns llama-server and can read systemctl status and the :8080 pid. A root
|
||||||
|
# probe reads as UNREACHABLE for a healthy host (the reported bug). Execute
|
||||||
|
# check_gpu_ports() against an SSH boundary that only accepts llmuser@.8 and
|
||||||
|
# assert the .8 leg does not produce the false UNREACHABLE failure.
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake_ssh(host, cmd, user="root"):
|
||||||
|
seen.append((host, user))
|
||||||
|
if host == "192.168.68.8" and user != "llmuser":
|
||||||
|
return None # root SSH denied -> baseline false UNREACHABLE
|
||||||
|
if cmd.startswith("systemctl is-active"):
|
||||||
|
return "active"
|
||||||
|
if cmd.startswith("ss -tlnp"):
|
||||||
|
return "48351"
|
||||||
|
if cmd.startswith("curl"):
|
||||||
|
return '{"status":"ok"}'
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(ahc, "ssh", fake_ssh)
|
||||||
|
ahc.FAIL.clear()
|
||||||
|
try:
|
||||||
|
ahc.check_gpu_ports()
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "gpu-unreachable:192.168.68.8" not in ahc.FAIL
|
||||||
|
assert "\u2705 gpu-rtx3090 (.8): healthy" in out
|
||||||
|
assert ("192.168.68.8", "llmuser") in seen
|
||||||
|
assert not any(host == "192.168.68.8" and user == "root" for host, user in seen)
|
||||||
|
finally:
|
||||||
|
ahc.FAIL.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_report_only_legs_never_count_as_failures(ahc):
|
def test_report_only_legs_never_count_as_failures(ahc):
|
||||||
for agent, report_only in (("koby", True), ("koonimo", False), ("tanko", False)):
|
for agent, report_only in (("koby", True), ("koonimo", False), ("tanko", False)):
|
||||||
ahc.FAIL.clear()
|
ahc.FAIL.clear()
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|||||||
ZULIP_MONITOR = ROOT / "scripts" / "zulip-monitor.sh"
|
ZULIP_MONITOR = ROOT / "scripts" / "zulip-monitor.sh"
|
||||||
CONNECTED_FIXTURE = ROOT / "tests" / "fixtures" / "zulip-health-connected.json"
|
CONNECTED_FIXTURE = ROOT / "tests" / "fixtures" / "zulip-health-connected.json"
|
||||||
|
|
||||||
TANKO_VANTAGE = "192.168.68.15" # amdpve — Tanko CT 112 via pct exec
|
TANKO_VANTAGE = "192.168.68.12" # minipve — Tanko CT 112 via pct exec
|
||||||
AGENT_ZERO_HOST = "192.168.68.14" # kagentz host, Agent Zero docker
|
AGENT_ZERO_HOST = "192.168.68.14" # kagentz host, Agent Zero docker
|
||||||
|
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ done
|
|||||||
printf '%s\n' "$host" >> "$RECORD_DIR/ssh.hosts"
|
printf '%s\n' "$host" >> "$RECORD_DIR/ssh.hosts"
|
||||||
cmd="${*: -1}"
|
cmd="${*: -1}"
|
||||||
case "$host" in
|
case "$host" in
|
||||||
192.168.68.15)
|
192.168.68.12)
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
*"systemctl is-active"*) printf '%s' "$TANKO_SVC" ;;
|
*"systemctl is-active"*) printf '%s' "$TANKO_SVC" ;;
|
||||||
*curl*) printf '%s' "$TANKO_HTTP" ;;
|
*curl*) printf '%s' "$TANKO_HTTP" ;;
|
||||||
|
|||||||
+24
-24
@@ -28,7 +28,7 @@ session start.
|
|||||||
## Requires
|
## Requires
|
||||||
|
|
||||||
- **Zulip API key** for `abiba-bot@chat.sysloggh.net` in `$ZULIP_API_KEY`
|
- **Zulip API key** for `abiba-bot@chat.sysloggh.net` in `$ZULIP_API_KEY`
|
||||||
- **SSH access** to amdpve (192.168.68.15) for Tanko — CT 112 reached via `pct exec` (direct SSH to .122 is not a dependency of this contract: per-worker key availability varies); and the Agent Zero Docker host (192.168.68.14)
|
- **SSH access** to minipve (192.168.68.12) for Tanko — CT 112 reached via `pct exec` (direct SSH to .122 is not a dependency of this contract: per-worker key availability varies); and the Agent Zero Docker host (192.168.68.14)
|
||||||
- **PM2** on localhost for pi process management
|
- **PM2** on localhost for pi process management
|
||||||
- **Network access** to `chat.sysloggh.net`, `kagentz.sysloggh.net` (C3 public path), `localhost:9200`
|
- **Network access** to `chat.sysloggh.net`, `kagentz.sysloggh.net` (C3 public path), `localhost:9200`
|
||||||
- **Write access** to `/root/zulip-health-monitor.log` and `/tmp/zulip-monitor-debounce`
|
- **Write access** to `/root/zulip-health-monitor.log` and `/tmp/zulip-monitor-debounce`
|
||||||
@@ -228,20 +228,20 @@ grep -a "Finalized\|Failed to finalize" /root/.pm2/logs/abiba-zulip-out.log | ta
|
|||||||
| Crash loop >10/h | Alert user |
|
| Crash loop >10/h | Alert user |
|
||||||
|
|
||||||
|
|
||||||
### Step 3: Platform B — Tanko (DSH on amdpve CT 112)
|
### Step 3: Platform B — Tanko (DSH on minipve CT 112)
|
||||||
|
|
||||||
Mumuni is out of scope for this host (see the note above): she runs on her own
|
Mumuni is out of scope for this host (see the note above): she runs on her own
|
||||||
container and is monitored on her side.
|
container and is monitored on her side.
|
||||||
|
|
||||||
Tanko runs on DSH (DeepSeek Harness) — it no longer runs a Hermes gateway, so
|
Tanko runs on DSH (DeepSeek Harness) — it no longer runs a Hermes gateway, so
|
||||||
there is no `~/.hermes/gateway_state.json` on CT 112. Tanko's Zulip gateway runs
|
there is no `~/.hermes/gateway_state.json` on CT 112. Tanko's Zulip gateway runs
|
||||||
as the `dsh-web` systemd unit inside **CT 112**, which resides on the **amdpve**
|
as the `dsh-web` systemd unit inside **CT 112**, which resides on the **minipve**
|
||||||
PVE host (**192.168.68.15**). Direct SSH to 192.168.68.122 is not a dependency
|
PVE host (**192.168.68.12**). Direct SSH to 192.168.68.122 is not a dependency
|
||||||
of this contract — per-worker key availability varies — so CT 112 probes run
|
of this contract — per-worker key availability varies — so CT 112 probes run
|
||||||
from the amdpve vantage via `pct exec`:
|
from the minipve vantage via `pct exec`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- <command>"
|
ssh root@192.168.68.12 "pct exec 112 -- <command>"
|
||||||
```
|
```
|
||||||
|
|
||||||
> **By design (verified 2026-09-08):** the `dsh-web` gateway binds
|
> **By design (verified 2026-09-08):** the `dsh-web` gateway binds
|
||||||
@@ -253,7 +253,7 @@ ssh root@192.168.68.15 "pct exec 112 -- <command>"
|
|||||||
**B1: Gateway Service State (Tanko)**
|
**B1: Gateway Service State (Tanko)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- systemctl is-active dsh-web"
|
ssh root@192.168.68.12 "pct exec 112 -- systemctl is-active dsh-web"
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected: `active`. Anything else → gateway service down → apply the Tanko heal
|
Expected: `active`. Anything else → gateway service down → apply the Tanko heal
|
||||||
@@ -262,7 +262,7 @@ Expected: `active`. Anything else → gateway service down → apply the Tanko h
|
|||||||
**B2: Gateway HTTP Liveness (Tanko — loopback-only :3080)**
|
**B2: Gateway HTTP Liveness (Tanko — loopback-only :3080)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/"
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s --connect-timeout 5 --max-time 10 -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/"
|
||||||
```
|
```
|
||||||
|
|
||||||
Alive = **ANY** HTTP status response from the endpoint — the expected set is
|
Alive = **ANY** HTTP status response from the endpoint — the expected set is
|
||||||
@@ -273,13 +273,13 @@ process answering `503` is running and self-heal must NOT restart-loop it.
|
|||||||
Down = connection refused (`000`) or timeout only. Statuses outside the
|
Down = connection refused (`000`) or timeout only. Statuses outside the
|
||||||
expected set are logged/reported as a warning — reported, never healed on.
|
expected set are logged/reported as a warning — reported, never healed on.
|
||||||
|
|
||||||
**B3: Public-URL Fallback Probe (Tanko — for nodes without pct/ssh access to amdpve)**
|
**B3: Public-URL Fallback Probe (Tanko — for nodes without pct/ssh access to minipve)**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s --connect-timeout 10 --max-time 15 -o /dev/null -w '%{http_code}' https://tankodhs.sysloggh.net/
|
curl -s --connect-timeout 10 --max-time 15 -o /dev/null -w '%{http_code}' https://tankodhs.sysloggh.net/
|
||||||
```
|
```
|
||||||
|
|
||||||
Fallback only — used when the monitoring node has no pct/SSH path to amdpve.
|
Fallback only — used when the monitoring node has no pct/SSH path to minipve.
|
||||||
Alive = **ANY** HTTP status response from the endpoint — healthy signals are
|
Alive = **ANY** HTTP status response from the endpoint — healthy signals are
|
||||||
`302` (authentik proxy-auth redirect) and `401` (auth-gated), and any other
|
`302` (authentik proxy-auth redirect) and `401` (auth-gated), and any other
|
||||||
status, including `404`/`5xx`, also counts alive: the endpoint is up and
|
status, including `404`/`5xx`, also counts alive: the endpoint is up and
|
||||||
@@ -404,29 +404,29 @@ ExecStartPost=/bin/systemctl --no-block start dsh-web-token.service
|
|||||||
4. Every later request through `/` presents that cookie; the token is not needed
|
4. Every later request through `/` presents that cookie; the token is not needed
|
||||||
again until the cookie expires or a new browser is used.
|
again until the cookie expires or a new browser is used.
|
||||||
|
|
||||||
**Verification** (amdpve vantage):
|
**Verification** (minipve vantage):
|
||||||
```bash
|
```bash
|
||||||
# 1. Login endpoint is Authentik-gated: unauthenticated -> 302 (not 200/303).
|
# 1. Login endpoint is Authentik-gated: unauthenticated -> 302 (not 200/303).
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}\n' \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}\n' \
|
||||||
-H 'Host: tankodhs.sysloggh.net' http://127.0.0.1/dsh-web-login"
|
-H 'Host: tankodhs.sysloggh.net' http://127.0.0.1/dsh-web-login"
|
||||||
# Expected: 302
|
# Expected: 302
|
||||||
|
|
||||||
# 2. Legacy :8081 endpoint is gone (connection refused -> 000).
|
# 2. Legacy :8081 endpoint is gone (connection refused -> 000).
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s --max-time 3 -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s --max-time 3 -o /dev/null \
|
||||||
-w '%{http_code}\n' http://192.168.68.122:8081/"
|
-w '%{http_code}\n' http://192.168.68.122:8081/"
|
||||||
# Expected: 000
|
# Expected: 000
|
||||||
|
|
||||||
# 3. Backend cookie mint + reuse (exactly what /dsh-web-login proxies to).
|
# 3. Backend cookie mint + reuse (exactly what /dsh-web-login proxies to).
|
||||||
TOKEN=$(ssh root@192.168.68.15 "pct exec 112 -- cat /etc/dsh-web/launch-token")
|
TOKEN=$(ssh root@192.168.68.12 "pct exec 112 -- cat /etc/dsh-web/launch-token")
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -c /tmp/dsh.jar -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -c /tmp/dsh.jar -o /dev/null \
|
||||||
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'"
|
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'"
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \
|
||||||
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
||||||
# Expected: 200 — the minted dsh-auth-... cookie (authority
|
# Expected: 200 — the minted dsh-auth-... cookie (authority
|
||||||
# tankodhs.sysloggh.net) is replayed on the next request and accepted.
|
# tankodhs.sysloggh.net) is replayed on the next request and accepted.
|
||||||
|
|
||||||
# 4. Token refresh is non-disruptive and idempotent.
|
# 4. Token refresh is non-disruptive and idempotent.
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- /opt/deepseek-harness/capture-dsh-token.sh"
|
ssh root@192.168.68.12 "pct exec 112 -- /opt/deepseek-harness/capture-dsh-token.sh"
|
||||||
# Expected: "token unchanged; nginx not reloaded" when nothing changed
|
# Expected: "token unchanged; nginx not reloaded" when nothing changed
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -437,32 +437,32 @@ fresh cookie. Both verified live 2026-09-11.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 5. Cookie survives a dsh-web restart, and the new token mints a new cookie.
|
# 5. Cookie survives a dsh-web restart, and the new token mints a new cookie.
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- systemctl restart dsh-web"
|
ssh root@192.168.68.12 "pct exec 112 -- systemctl restart dsh-web"
|
||||||
# dsh-web is Type=simple: restart returns before :3080 is listening. Bounded-poll
|
# dsh-web is Type=simple: restart returns before :3080 is listening. Bounded-poll
|
||||||
# until the socket answers (any status but 000) before asserting the cookie.
|
# until the socket answers (any status but 000) before asserting the cookie.
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
UP=$(ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \
|
UP=$(ssh root@192.168.68.12 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \
|
||||||
-H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/")
|
-H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/")
|
||||||
[ "$UP" != "000" ] && break
|
[ "$UP" != "000" ] && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -b /tmp/dsh.jar -o /dev/null \
|
||||||
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
||||||
# Expected: 200 — the pre-restart cookie is still accepted.
|
# Expected: 200 — the pre-restart cookie is still accepted.
|
||||||
# The restart's ExecStartPost (or the 2-minute timer) refreshes the include. A
|
# The restart's ExecStartPost (or the 2-minute timer) refreshes the include. A
|
||||||
# manual run may no-op on the flock, so poll until the include carries a token
|
# manual run may no-op on the flock, so poll until the include carries a token
|
||||||
# the running process accepts (bounded wait) before the mint+reuse check.
|
# the running process accepts (bounded wait) before the mint+reuse check.
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
TOKEN=$(ssh root@192.168.68.15 "pct exec 112 -- sed -n 's/.*token=//p' /etc/dsh-web/nginx-login.conf | tr -d ';\n'")
|
TOKEN=$(ssh root@192.168.68.12 "pct exec 112 -- sed -n 's/.*token=//p' /etc/dsh-web/nginx-login.conf | tr -d ';\n'")
|
||||||
CODE=$(ssh root@192.168.68.15 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \
|
CODE=$(ssh root@192.168.68.12 "pct exec 112 -- curl -s -o /dev/null -w '%{http_code}' \
|
||||||
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'")
|
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'")
|
||||||
[ "$CODE" = "303" ] && break
|
[ "$CODE" = "303" ] && break
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
# Expected: 303 — the include now holds the token the running process accepts.
|
# Expected: 303 — the include now holds the token the running process accepts.
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -c /tmp/dsh-new.jar -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -c /tmp/dsh-new.jar -o /dev/null \
|
||||||
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'"
|
-H 'Host: tankodhs.sysloggh.net' 'http://127.0.0.1:3080/?token=$TOKEN'"
|
||||||
ssh root@192.168.68.15 "pct exec 112 -- curl -s -b /tmp/dsh-new.jar -o /dev/null \
|
ssh root@192.168.68.12 "pct exec 112 -- curl -s -b /tmp/dsh-new.jar -o /dev/null \
|
||||||
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
-w '%{http_code}\n' -H 'Host: tankodhs.sysloggh.net' http://127.0.0.1:3080/"
|
||||||
# Expected: 200 — the refreshed token minted a fresh cookie.
|
# Expected: 200 — the refreshed token minted a fresh cookie.
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user