610 lines
27 KiB
Python
Executable File
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())
|