Files
2026-09-15 15:24:03 -04:00

610 lines
27 KiB
Python
Executable File

#!/usr/bin/env python3
"""Check what is set up, what is switched off, and what is broken.
./verify.py the stack and every tool
./verify.py --quick skip the tools that reach the internet
./verify.py --tool NAME just one
WHAT IT IS FOR
There are three different ways a tool can be "not working" and they want
three different responses, so they are reported differently:
All Set a tool that is switched on and answers
All Set, Disabled works, but you have turned it off
Needs Setup, Disabled off, and would not work yet anyway
Needs Setup, Enabled ON and NOT READY - the one to fix first
Error, Enabled on, set up, and failing. Gets its own log file.
Error, Disabled failing, but nobody is being offered it
The terminal output is a summary. Everything that was actually checked,
including what each probe returned, goes to logs/verify.log - and each
tool that ERRORS also gets logs/verify-error-<tool>.log with just its own
story, because that is the file you want when one thing is broken and
twenty are fine.
Nothing here changes anything. It reads config, asks Docker what is
running, and calls each tool once with a known-good argument.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import tomllib
import urllib.error
import urllib.request
from datetime import datetime, timezone
ROOT = os.path.dirname(os.path.abspath(__file__))
LOG_DIR = os.path.join(ROOT, "logs")
LOG_PATH = os.path.join(LOG_DIR, "verify.log")
# Status values, worst last - the summary counts by these.
ALL_SET, NEEDS_SETUP, ERROR, UNKNOWN = "All Set", "Needs Setup", "Error", "Unknown"
# One known-good call per tool, lifted from the smoke suite so these are
# arguments that are known to work rather than ones that look right.
# `net` means it leaves the machine, so --quick skips it.
PROBES: dict[str, dict] = {
"subnet_calc": {"args": {"network": "192.168.1.0/24"}, "want": "192.168.1.255"},
"ohms_law_power": {"args": {"voltage": "12", "resistance": "220"}, "want": "54.55 mA"},
"component_calc": {"args": {"calculation": "resistor_color_code",
"text": "brown black red gold"}, "want": "1 kΩ"},
"pcb_calc": {"args": {"calculation": "trace_width", "current": "2"}, "want": "mm"},
"wiring_calc": {"args": {"calculation": "awg_properties", "awg": 14}, "want": "mm²"},
"network_reference": {"args": {"lookup_type": "port", "value": "443"}, "want": "https"},
"matlab_run": {"args": {"code": "disp(6*7)"}, "want": "42"},
"web_search": {"args": {"query": "systemd socket activation", "max_results": 3},
"net": True},
"fetch_page": {"args": {"url": "https://example.com", "max_chars": 500}, "net": True},
"wikipedia_lookup": {"args": {"topic": "OSI model"}, "net": True},
"search_stackexchange": {"args": {"query": "bash trap EXIT", "site": "unix",
"max_results": 2}, "net": True},
"search_papers": {"args": {"query": "GaN transistor switching losses",
"max_results": 2}, "net": True},
"find_datasheet": {"args": {"part_number": "LM358"}, "net": True},
"lookup_rfc": {"args": {"number": 9293, "offset": 0}, "net": True},
"lookup_cve": {"args": {"cve_id": "CVE-2021-44228"}, "net": True},
"read_man_page": {"args": {"name": "ip", "section": 8}, "net": True},
"lookup_package": {"args": {"name": "requests", "ecosystem": "pypi"}, "net": True},
"dns_lookup": {"args": {"name": "cloudflare.com", "record_type": "A"}, "net": True},
"inspect_tls": {"args": {"hostname": "example.com"}, "net": True},
"http_headers": {"args": {"url": "https://example.com"}, "net": True},
# A REAL address, not 203.0.113.x. The smoke suite uses a
# documentation range there deliberately, to prove the tool refuses
# it - which is the opposite of what a health probe wants, and it
# reported this tool broken until the payload was fixed.
"lookup_ip_or_asn": {"args": {"target": "8.8.8.8"}, "want": "AS", "net": True},
# vm_agent is deliberately absent: calling it starts a real machine.
# It is checked structurally by check_vm_agent() instead.
}
# What a tool needs beyond the stack being up, and where that is written
# down. Only HARD requirements - an optional API key that lifts a rate
# limit is a note, not a setup step, and reporting it as one would train
# you to ignore the column.
SETUP_DOC = {
"web_search": "docs/setup/searxng.md",
"find_datasheet": "docs/setup/searxng.md",
"wikipedia_lookup": "docs/setup/contact.md",
"matlab_run": "docs/setup/octave.md",
"vm_agent": "docs/setup/vm-agent.md",
}
class Report:
"""Collects the detail for the log while the terminal stays short."""
def __init__(self) -> None:
self.lines: list[str] = []
self.rows: list[tuple[str, str, str, str, str]] = [] # section,name,status,enabled,note
self.errors: dict[str, list[str]] = {}
def detail(self, text: str = "") -> None:
self.lines.append(text)
def row(self, section: str, name: str, status: str,
enabled: str = "", note: str = "") -> None:
self.rows.append((section, name, status, enabled, note))
self.detail(f" [{status}] {name}" + (f" ({enabled})" if enabled else "")
+ (f" - {note}" if note else ""))
def error_detail(self, tool: str, text: str) -> None:
self.errors.setdefault(tool, []).append(text)
def run(*argv: str, timeout: float = 20.0) -> tuple[int, str]:
try:
done = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
return done.returncode, (done.stdout + done.stderr).strip()
except FileNotFoundError:
return 127, f"{argv[0]} is not installed"
except subprocess.TimeoutExpired:
return 124, f"{argv[0]} timed out after {timeout}s"
except Exception as exc: # noqa: BLE001
return 1, f"{type(exc).__name__}: {exc}"
# ---------------------------------------------------------------------
# configuration
# ---------------------------------------------------------------------
def read_toggles(report: Report) -> dict[str, bool]:
path = os.path.join(ROOT, "configs", "tools.toml")
try:
with open(path, "rb") as handle:
table = tomllib.load(handle).get("tools", {})
report.detail(f" read {path}: {len(table)} tools listed")
return {str(k): bool(v) for k, v in table.items()}
except FileNotFoundError:
report.detail(f" {path} is missing - every tool counts as enabled")
return {}
except (OSError, tomllib.TOMLDecodeError) as exc:
report.detail(f" {path} could not be read ({exc}) - every tool counts as enabled")
return {}
def read_env(report: Report) -> dict[str, str]:
"""configs/owuitools.env, which is also what `.env` points at."""
path = os.path.join(ROOT, "configs", "owuitools.env")
values: dict[str, str] = {}
try:
for line in open(path, encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
values[key.strip()] = value.strip().strip('"').strip("'")
except OSError:
report.detail(f" {path} is missing")
return values
report.detail(f" read {path}: {len(values)} settings")
return values
# ---------------------------------------------------------------------
# the stack
# ---------------------------------------------------------------------
def check_stack(report: Report) -> tuple[bool, dict[str, str]]:
"""Which containers exist and what Docker thinks of them."""
report.detail("\n=== stack ===")
if not shutil.which("docker"):
report.row("stack", "docker", ERROR, note="not installed")
return False, {}
# `-a`, because `docker compose ps` lists only what is RUNNING - a
# container that exited simply vanishes from the output, and the
# check then reports the stack healthy while a service is down.
# Measured: stopping octave left this saying "All Set, 9 running".
code, out = run("docker", "compose", "ps", "-a", "--format",
"{{.Name}}\t{{.Service}}\t{{.State}}\t{{.Health}}", timeout=30)
if code != 0:
# The commonest cause by far, and it is a consequence of the row
# above rather than a second fault: with no configs/owuitools.env
# there is nothing for compose to substitute, so it refuses to
# read the file at all. Saying that is more use than quoting its
# interpolation error.
if "interpolat" in out or "required variable" in out.lower():
report.row("stack", "docker compose", NEEDS_SETUP,
note="cannot read docker-compose.yml until "
"configs/owuitools.env exists - see above")
else:
report.row("stack", "docker compose", ERROR,
note=summarise(out) if out else "failed")
report.error_detail("stack", out)
return False, {}
states: dict[str, str] = {}
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 3 or not parts[0].startswith("owuitools"):
continue
name, service, state = parts[0], parts[1], parts[2]
health = parts[3] if len(parts) > 3 else ""
states[service] = state if not health else f"{state}/{health}"
report.detail(f" {name}: state={state} health={health or 'n/a'}")
if not states:
report.row("stack", "containers", ERROR, note="none running - `docker compose up -d`")
return False, states
# And a service declared in the compose file that has no container at
# all is missing, not merely stopped.
code, declared_out = run("docker", "compose", "config", "--services", timeout=30)
declared = set(declared_out.split()) if code == 0 else set()
absent = sorted(declared - set(states))
for service in absent:
states[service] = "absent"
report.detail(f" {service}: declared in docker-compose.yml, no container")
unhealthy = [s for s, v in states.items() if not v.startswith("running")
or v.endswith("/unhealthy")]
if unhealthy:
report.row("stack", "containers", ERROR,
note=f"{len(unhealthy)} of {len(states)} not running: "
f"{', '.join(f'{s} ({states[s]})' for s in sorted(unhealthy))}")
report.error_detail("stack", "container states:\n" +
"\n".join(f" {s} = {v}" for s, v in sorted(states.items())))
else:
report.row("stack", "containers", ALL_SET, note=f"{len(states)} running")
# WHAT THIS RETURNS IS "CAN I PROBE", NOT "IS EVERYTHING WELL".
#
# They are different questions and conflating them was wrong: with
# octave stopped, every one of the other 21 tools was reported "Not
# Checked" because one unrelated container was down. Probing needs
# the api container and nothing else; each tool's own dependency is
# reported by the tool's own row.
api = states.get("api", "")
probeable = api.startswith("running") and not api.endswith("/unhealthy")
if not probeable:
report.detail(f" api is {api or 'absent'} - individual tools cannot be probed")
return probeable, states
def check_logs_dir(report: Report) -> None:
report.detail("\n=== logs ===")
if not os.path.isdir(LOG_DIR):
report.row("stack", "logs directory", ERROR, note=f"{LOG_DIR} does not exist")
return
if not os.access(LOG_DIR, os.W_OK):
report.row("stack", "logs directory", ERROR, note=f"{LOG_DIR} is not writable")
return
written = sorted(f for f in os.listdir(LOG_DIR) if f.endswith(".log")
and not f.startswith("verify"))
for name in written:
size = os.path.getsize(os.path.join(LOG_DIR, name))
report.detail(f" {name}: {size} bytes")
if written:
report.row("stack", "logs", ALL_SET, note=f"{len(written)} components writing")
else:
report.row("stack", "logs", NEEDS_SETUP,
note="no component has written yet - is the stack up?")
# ---------------------------------------------------------------------
# the tools
# ---------------------------------------------------------------------
def spec_tools(report: Report) -> set[str] | None:
"""What the running server actually offers, from its own OpenAPI spec."""
code, out = run("docker", "compose", "exec", "-T", "api", "python", "-c",
"import json,urllib.request;"
"s=json.load(urllib.request.urlopen('http://127.0.0.1:8000/openapi.json'));"
"print(json.dumps(sorted({o.get('operationId') "
"for p in s['paths'].values() for o in p.values()} - {None})))",
timeout=45)
if code != 0:
report.detail(f" could not read the live spec: {out[:200]}")
return None
try:
names = set(json.loads(out.splitlines()[-1]))
except (ValueError, IndexError):
report.detail(f" the spec did not parse: {out[:200]}")
return None
report.detail(f" live spec offers {len(names)} tools: {', '.join(sorted(names))}")
return names
def call_tool(tool: str, args: dict, timeout: float = 90.0) -> tuple[bool, str]:
"""One real call, through the api container - the same path a tool call takes."""
script = (
"import json,os,sys,urllib.error,urllib.request\n"
"body=json.loads(os.environ['BODY'])\n"
"r=urllib.request.Request('http://127.0.0.1:8000/tools/'+os.environ['TOOL'],\n"
" data=json.dumps(body).encode(),\n"
" headers={'Authorization':'Bearer '+os.environ['OWUITOOLS_AUTH_TOKEN'],\n"
" 'Content-Type':'application/json'})\n"
"try:\n"
" print(urllib.request.urlopen(r,timeout=" + str(int(timeout) - 10) + ").read().decode())\n"
"except urllib.error.HTTPError as e:\n"
" sys.exit('HTTP %d: %s' % (e.code, e.read().decode()[:300]))\n"
)
code, out = run("docker", "compose", "exec", "-T",
"-e", f"TOOL={tool}", "-e", f"BODY={json.dumps(args)}",
"api", "python", "-c", script, timeout=timeout)
return code == 0, out
# What each tool depends on, for the case where it cannot be probed
# because it is switched off. Only hard dependencies: an optional API key
# that lifts a rate limit does not belong here.
DEPENDS_ON_SERVICE = {
"web_search": "searxng",
"find_datasheet": "searxng",
"matlab_run": "octave",
}
def summarise(out: str) -> str:
"""The one line worth putting on screen.
A Python traceback's FIRST line is "Traceback (most recent call
last):", which says nothing; the last line is the exception. Anything
else is usually most informative at the top. The full text goes to
the per-tool error log either way.
"""
lines = [l.rstrip() for l in out.splitlines() if l.strip()]
if not lines:
return "the call failed with no output"
if lines[0].startswith("Traceback"):
return lines[-1].strip()[:110]
return lines[0].strip()[:110]
def missing_prerequisite(tool: str, env: dict[str, str],
states: dict[str, str]) -> str:
"""Why this tool would not work if you switched it on, or ''."""
service = DEPENDS_ON_SERVICE.get(tool)
if service:
state = states.get(service, "")
if not state:
return f"needs the {service} container, which is not running"
if not state.startswith("running") or state.endswith("/unhealthy"):
return f"needs {service}, which is {state}"
if tool == "wikipedia_lookup":
contact = env.get("OWUITOOLS_CONTACT", "")
if not contact or contact.endswith("@localhost"):
return "OWUITOOLS_CONTACT is unset - Wikimedia returns 403"
return ""
def classify(tool: str, enabled: bool, offered: set[str] | None,
env: dict[str, str], states: dict[str, str], quick: bool,
report: Report) -> tuple[str, str]:
"""(status, note) for one tool."""
probe = PROBES.get(tool)
# --- hard prerequisites, checked before spending a call ------------
if tool == "wikipedia_lookup":
contact = env.get("OWUITOOLS_CONTACT", "")
if not contact or contact.endswith("@localhost"):
return NEEDS_SETUP, "OWUITOOLS_CONTACT is unset - Wikimedia returns 403"
if not enabled:
# A disabled tool has no route left, so it CANNOT be probed. The
# useful half of the question is still answerable though: are the
# things it depends on in place? Answering that structurally is
# what makes "All Set, Disabled" a claim rather than a shrug.
blocker = missing_prerequisite(tool, env, states)
if blocker:
return NEEDS_SETUP, blocker
return ALL_SET, "not probed - it is switched off"
if offered is not None and tool not in offered:
return ERROR, "enabled in config but missing from the live spec - restart api"
# Ask the cheap question first. If the container this tool depends on
# is down, probing it just waits out the timeout and then reports
# "TimeoutError: timed out", which is true and useless - "needs
# octave, which is exited" is the same finding with the cause in it.
blocker = missing_prerequisite(tool, env, states)
if blocker:
report.detail(f" {tool}: not probed - {blocker}")
return NEEDS_SETUP, blocker
if probe is None:
return UNKNOWN, "no probe for this tool"
if quick and probe.get("net"):
return UNKNOWN, "skipped (--quick)"
started = time.monotonic()
ok, out = call_tool(tool, probe["args"])
took = time.monotonic() - started
report.detail(f" probe {tool}({json.dumps(probe['args'])[:90]}) "
f"-> {'ok' if ok else 'FAILED'} in {took:.1f}s")
report.detail(f" {out[:300]}".rstrip())
if not ok:
report.error_detail(tool, f"call: {tool} {json.dumps(probe['args'])}\n\n{out}")
return ERROR, summarise(out)
# The tool answered. Did it answer with a refusal or a finding?
first = out.strip().strip('"')
for bad in ("CANNOT DO THAT", "FAILED:", "NO RESULTS"):
if first.startswith(bad):
report.error_detail(tool, f"call: {tool} {json.dumps(probe['args'])}\n\n{out}")
return ERROR, first[:70]
want = probe.get("want")
if want and want not in out:
report.error_detail(
tool, f"call: {tool} {json.dumps(probe['args'])}\n"
f"expected to contain: {want!r}\n\n{out}")
return ERROR, f"answered, but not with {want!r}"
return ALL_SET, f"{took:.1f}s"
def check_vm_agent(report: Report, enabled: bool) -> tuple[str, str]:
"""Structural only. Calling vm_agent would start a real machine."""
report.detail("\n=== vm_agent (structural - calling it would boot a VM) ===")
missing: list[str] = []
code, out = run("systemctl", "is-active", "owuivmd.service", timeout=15)
active = out.strip() == "active"
report.detail(f" owuivmd.service: {out.strip() or 'unknown'}")
if not active:
missing.append("the host daemon is not running (sudo ./vm/host/install.sh)")
base = "/var/lib/owuivm/base"
images: list[str] = []
for name in ("ubuntu", "windows"):
disk = os.path.join(base, f"{name}.qcow2")
try:
mode = os.stat(disk).st_mode
frozen = not bool(mode & 0o200)
size = os.path.getsize(disk) / 1e9
report.detail(f" {disk}: {size:.1f} GB, {'frozen' if frozen else 'UNFROZEN'}")
images.append(name if frozen else f"{name} (UNFROZEN)")
except OSError:
report.detail(f" {disk}: absent")
if not images:
missing.append("no base image (sudo ./vm/images/import.sh ubuntu)")
unfrozen = [i for i in images if "UNFROZEN" in i]
if unfrozen:
# Not "needs setup" - this actively stops every session, and the
# fix is one button rather than an install step.
return ERROR, f"{unfrozen[0].split()[0]} is unfrozen; no session can start"
code, out = run("sudo", "-n", "grep", "-c", "^OWUITOOLS_GITEA_TOKEN=.",
"/etc/owuivm/vmd.env", timeout=10)
has_gitea = code == 0 and out.strip() == "1"
report.detail(f" gitea token present: {has_gitea}"
+ ("" if code in (0, 1) else f" (could not check: {out[:60]})"))
if missing:
return NEEDS_SETUP, missing[0]
note = f"{', '.join(images)} ready"
if not has_gitea:
note += "; no Gitea token, so sessions publish nothing"
return ALL_SET, note
# ---------------------------------------------------------------------
# output
# ---------------------------------------------------------------------
MARK = {ALL_SET: "ok", NEEDS_SETUP: "--", ERROR: "!!", UNKNOWN: "??"}
def label(status: str, enabled: bool) -> str:
"""The four-plus states, spelled the way the report spells them."""
if status == UNKNOWN:
return "Not Checked" + ("" if enabled else ", Disabled")
return status + ("" if enabled else ", Disabled")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--quick", action="store_true",
help="skip the tools that reach the internet")
parser.add_argument("--tool", help="check one tool only")
options = parser.parse_args()
os.chdir(ROOT)
report = Report()
started = datetime.now(timezone.utc)
report.detail(f"owuitools verification - {started:%Y-%m-%d %H:%M:%S} UTC")
report.detail(f"repository: {ROOT}")
report.detail("\n=== configuration ===")
toggles = read_toggles(report)
env = read_env(report)
if not env.get("OWUITOOLS_AUTH_TOKEN"):
report.row("stack", "configs/owuitools.env", NEEDS_SETUP,
note="no OWUITOOLS_AUTH_TOKEN - see docs/install-ubuntu-desktop.md")
else:
report.row("stack", "configs/owuitools.env", ALL_SET, note="token set")
up, states = check_stack(report)
check_logs_dir(report)
report.detail("\n=== tools ===")
offered = spec_tools(report) if up else None
names = sorted(set(toggles) | set(PROBES) | {"vm_agent"})
if options.tool:
names = [n for n in names if n == options.tool]
if not names:
print(f"no tool called {options.tool!r}", file=sys.stderr)
return 2
for tool in names:
enabled = toggles.get(tool, True)
if tool == "vm_agent":
status, note = check_vm_agent(report, enabled)
elif not up:
status, note = UNKNOWN, "the stack is not running"
else:
status, note = classify(tool, enabled, offered, env, states,
options.quick, report)
if status == NEEDS_SETUP and tool in SETUP_DOC:
note = f"{note} -> {SETUP_DOC[tool]}" if note else SETUP_DOC[tool]
report.row("tool", tool, status, "enabled" if enabled else "disabled", note)
return emit(report, started, options)
def emit(report: Report, started: datetime, options: argparse.Namespace) -> int:
"""The short version on screen, everything in the log."""
os.makedirs(LOG_DIR, exist_ok=True)
tools = [r for r in report.rows if r[0] == "tool"]
stack = [r for r in report.rows if r[0] == "stack"]
width = max([len(r[1]) for r in report.rows] + [12])
print(f"\n OWUI Tools — {started:%Y-%m-%d %H:%M} UTC\n")
print(" STACK")
for _, name, status, _, note in stack:
print(f" {MARK[status]} {name:<{width}} {status}"
+ (f" {note}" if note else ""))
print("\n TOOLS")
for _, name, status, enabled, note in tools:
shown = label(status, enabled == "enabled")
line = f" {MARK[status]} {name:<{width}} {shown}"
# Only the things you can act on get a trailing note: a working
# tool's timing is in the log, not on the screen.
if status in (NEEDS_SETUP, ERROR) and note:
line += f"\n {note}"
print(line)
counts = {s: sum(1 for r in tools if r[2] == s) for s in (ALL_SET, NEEDS_SETUP, ERROR, UNKNOWN)}
off = sum(1 for r in tools if r[3] == "disabled")
# The error files, one per failing tool, written before the summary
# points at them.
written: list[str] = []
for tool, lines in sorted(report.errors.items()):
path = os.path.join(LOG_DIR, f"verify-error-{tool}.log")
with open(path, "w", encoding="utf-8") as handle:
handle.write(f"owuitools verification - {started:%Y-%m-%d %H:%M:%S} UTC\n")
handle.write(f"what failed: {tool}\n")
if tool in SETUP_DOC:
handle.write(f"setup notes: {SETUP_DOC[tool]}\n")
handle.write("\n" + "\n\n".join(lines).rstrip() + "\n")
written.append(path)
# Anything that errored last time and is fine now should not leave a
# stale file behind to be found later and believed.
for stale in sorted(os.listdir(LOG_DIR)):
if stale.startswith("verify-error-") and os.path.join(LOG_DIR, stale) not in written:
os.unlink(os.path.join(LOG_DIR, stale))
report.detail("")
report.detail(f"summary: {counts[ALL_SET]} all set, {off} disabled, "
f"{counts[NEEDS_SETUP]} need setup, {counts[ERROR]} erroring")
with open(LOG_PATH, "w", encoding="utf-8") as handle:
handle.write("\n".join(report.lines).rstrip() + "\n")
print(f"\n {len(tools)} tools: {counts[ALL_SET]} all set, {off} disabled, "
f"{counts[NEEDS_SETUP]} need setup, {counts[ERROR]} erroring"
+ (f", {counts[UNKNOWN]} not checked" if counts[UNKNOWN] else ""))
print(f" full log: {os.path.relpath(LOG_PATH, ROOT)}")
for path in written:
print(f" ERROR {os.path.relpath(path, ROOT)}")
print()
# 0 = nothing is broken. 1 = something enabled is failing, so the
# model will hit it. 2 = something needs attention but nobody is
# being offered it yet.
#
# STACK ROWS COUNT TOO. They did not at first, and an entirely
# unconfigured clone - no token, compose unreadable, nothing running
# - exited 0, which is the one case where a non-zero code is most
# obviously wanted.
if any(r[2] == ERROR and r[3] != "disabled" for r in report.rows):
return 1
if any(r[2] in (ERROR, NEEDS_SETUP) for r in report.rows):
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())