volt: spark/hermes_profile_health.py grades a named profile (Q81) -- --profile/NAME/HERMES_HOME replaces the module constant, is_dir() replaces exists() on the four state dirs, config.yaml is parsed by yaml.safe_load and graded non-empty/non-mapping, the .hermes_profile branch is deleted (0 of 27 profile homes carry it, 0 occurrences in the Hermes source tree), and rc 2 is reserved for a target that cannot be graded
This commit is contained in:
1 parent
d99b7d36fb
commit
bc8ccb1bd2
1 file changed
+138
-34
@@ -1,49 +1,153 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hermes Profile Health Checker - verifies spark profile structure.
|
||||
Exit 0 = healthy, 1 = issues found, 2 = critical missing
|
||||
"""Hermes profile health checker - grades ONE profile home.
|
||||
|
||||
Exit codes (troupe contract): 0 = clean, 1 = ran and named faults in the profile,
|
||||
2 = could not grade (no profile named, profile home absent or not a directory, or an
|
||||
input could not be read). A 2 never prints a verdict.
|
||||
|
||||
Profile home resolution, first match wins:
|
||||
1. --profile PATH (absolute, contains os.sep, or starts with '~' or '.')
|
||||
2. --profile NAME resolved under --profiles-root
|
||||
3. $HERMES_HOME Hermes' own variable for the active profile home
|
||||
--profiles-root defaults to $HERMES_PROFILES_DIR, else ~/.hermes/profiles.
|
||||
|
||||
Graded: config.yaml must exist, be non-empty, and yaml.safe_load to a non-empty
|
||||
mapping; skills/, plugins/, cron/, memories/ must be directories (a regular file of
|
||||
that name is a fault, not a pass).
|
||||
|
||||
Not graded, and not claimed: permissions, ownership, symlinked profile homes, whether
|
||||
individual skills/plugins load, and config.yaml's schema (any non-empty mapping
|
||||
passes; this tool does not know Hermes' key set).
|
||||
The `.hermes_profile` marker is neither required nor reported: no profile home on
|
||||
this host carries one (0 of 27) and nothing in the Hermes source tree writes it.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
PROFILE_DIR = Path("/root/.hermes/profiles/spark")
|
||||
EXPECTED_DIRS = ["skills", "plugins", "cron", "memories"]
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # graded as ungradable, never as a pass
|
||||
yaml = None
|
||||
|
||||
def check_profile():
|
||||
issues = []
|
||||
|
||||
# Check profile dir exists
|
||||
if not PROFILE_DIR.exists():
|
||||
print("CRITICAL: Profile directory missing")
|
||||
EXPECTED_DIRS = ("skills", "plugins", "cron", "memories")
|
||||
DEFAULT_PROFILES_ROOT = "~/.hermes/profiles"
|
||||
|
||||
|
||||
class Ungradable(Exception):
|
||||
"""Raised when no verdict can be earned; main() turns it into rc 2."""
|
||||
|
||||
|
||||
def resolve_home(profile, profiles_root):
|
||||
"""Map a --profile value (path or name) or $HERMES_HOME to a profile home path."""
|
||||
if profile:
|
||||
if (
|
||||
os.sep in profile
|
||||
or (os.altsep and os.altsep in profile)
|
||||
or profile.startswith("~")
|
||||
or profile.startswith(".")
|
||||
or Path(profile).is_absolute()
|
||||
):
|
||||
return Path(profile).expanduser()
|
||||
root = profiles_root or os.environ.get("HERMES_PROFILES_DIR") or DEFAULT_PROFILES_ROOT
|
||||
return Path(root).expanduser() / profile
|
||||
env_home = os.environ.get("HERMES_HOME", "").strip()
|
||||
if env_home:
|
||||
return Path(env_home).expanduser()
|
||||
raise Ungradable("no profile named: pass --profile PATH|NAME or set HERMES_HOME")
|
||||
|
||||
|
||||
def grade_config(home):
|
||||
"""Return (ok_lines, issues) for <home>/config.yaml."""
|
||||
cfg = home / "config.yaml"
|
||||
if not cfg.exists():
|
||||
return [], ["config.yaml missing"]
|
||||
if not cfg.is_file():
|
||||
return [], ["config.yaml exists but is not a regular file"]
|
||||
try:
|
||||
raw = cfg.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise Ungradable(f"config.yaml unreadable: {exc}") from exc
|
||||
if not raw.strip():
|
||||
return [], ["config.yaml is empty"]
|
||||
if yaml is None:
|
||||
raise Ungradable("PyYAML not importable, cannot grade config.yaml")
|
||||
try:
|
||||
data = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as exc:
|
||||
text = str(exc).strip()
|
||||
first = text.splitlines()[0] if text else exc.__class__.__name__
|
||||
return [], [f"config.yaml is not valid YAML: {first}"]
|
||||
if data is None:
|
||||
return [], ["config.yaml parses to no document"]
|
||||
if not isinstance(data, dict):
|
||||
return [], [f"config.yaml is a {type(data).__name__}, not a mapping"]
|
||||
if not data:
|
||||
return [], ["config.yaml is an empty mapping"]
|
||||
return [f"{cfg} ({len(data)} key(s), parsed)"], []
|
||||
|
||||
|
||||
def check_profile(home):
|
||||
"""Grade one profile home. Returns the process exit code."""
|
||||
home = Path(home)
|
||||
if not home.exists():
|
||||
print(f"ungradable: profile home does not exist: {home}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# Check config.yaml
|
||||
config = PROFILE_DIR / "config.yaml"
|
||||
if not config.exists():
|
||||
issues.append("config.yaml missing")
|
||||
else:
|
||||
print(f"OK: {config}")
|
||||
|
||||
# Check expected directories
|
||||
if not home.is_dir():
|
||||
print(f"ungradable: profile home is not a directory: {home}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
oks, issues = grade_config(home)
|
||||
for dirname in EXPECTED_DIRS:
|
||||
dirpath = PROFILE_DIR / dirname
|
||||
if not dirpath.exists():
|
||||
issues.append(f"{dirname}/ missing")
|
||||
dirpath = home / dirname
|
||||
if dirpath.is_dir():
|
||||
oks.append(f"{dirpath}/")
|
||||
elif dirpath.exists():
|
||||
issues.append(f"{dirname} exists but is not a directory")
|
||||
else:
|
||||
print(f"OK: {dirpath}/")
|
||||
|
||||
# Check magic file
|
||||
magic = PROFILE_DIR / ".hermes_profile"
|
||||
if magic.exists():
|
||||
print(f"OK: {magic}")
|
||||
|
||||
issues.append(f"{dirname}/ missing")
|
||||
|
||||
for line in oks:
|
||||
print(f"OK: {line}")
|
||||
if issues:
|
||||
print("\nIssues:")
|
||||
for i in issues:
|
||||
print(f" - {i}")
|
||||
for issue in issues:
|
||||
print(f" - {issue}")
|
||||
print(f"\nProfile is unhealthy: {len(issues)} issue(s)")
|
||||
return 1
|
||||
|
||||
print("\nProfile is healthy")
|
||||
|
||||
print(f"\nProfile is healthy: {home}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Grade one Hermes profile home (0 healthy / 1 faults / 2 ungradable).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
help="profile home PATH (absolute, or containing '/', '~', '.') or a profile NAME "
|
||||
"resolved under --profiles-root; defaults to $HERMES_HOME",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profiles-root",
|
||||
default=None,
|
||||
help="root for NAME resolution (default: $HERMES_PROFILES_DIR or "
|
||||
+ DEFAULT_PROFILES_ROOT + ")",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
home = resolve_home(args.profile, args.profiles_root)
|
||||
except Ungradable as exc:
|
||||
print(f"ungradable: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
return check_profile(home)
|
||||
except Ungradable as exc:
|
||||
print(f"ungradable: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(check_profile())
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user