251 lines
8.6 KiB
Python
251 lines
8.6 KiB
Python
"""The plugin update-check cadence: read-only, receipt-surfaced.
|
|
|
|
Runs at gateway start + the periodic tick when
|
|
``plugins.auto_update_check_hours`` (default 24, 0 disables) says it's
|
|
due, writes a plugin-check receipt (pm.receipt, kind 'plugin-check'),
|
|
and logs ONE actionable line when updates exist. Applying updates
|
|
stays explicit — ``plugins.auto_apply: true`` (default false) opts into
|
|
unattended apply for git-row plugins ONLY, scan-gated like cmd_update.
|
|
|
|
Pure, injectable clock/check/update seams for hermetic tests.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Callable, Optional
|
|
|
|
_MARKERS_DIR = "plugin-update-checks"
|
|
_DEFAULT_INTERVAL_HOURS = 24
|
|
|
|
|
|
def _marker_path() -> Path:
|
|
"""The last-run marker, resolved per call (tests monkeypatch
|
|
get_hermes_home). Derivation only — NO mkdir: a due-check or any
|
|
read path must not create user state."""
|
|
from hermes_constants import get_hermes_home
|
|
|
|
return get_hermes_home() / _MARKERS_DIR / "last-run"
|
|
|
|
|
|
def _markers_dir() -> Path:
|
|
"""The markers dir, created on demand — WRITE paths only."""
|
|
d = _marker_path().parent
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
return d
|
|
|
|
|
|
def check_interval_hours(config_get: Callable | None = None) -> float:
|
|
"""plugins.auto_update_check_hours; 0 disables; default 24."""
|
|
if config_get is None:
|
|
config_get = _default_config_get
|
|
try:
|
|
value = config_get("plugins", "auto_update_check_hours")
|
|
except Exception:
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
if value is None:
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
try:
|
|
hours = float(value)
|
|
except (TypeError, ValueError):
|
|
return _DEFAULT_INTERVAL_HOURS
|
|
return max(0.0, hours)
|
|
|
|
|
|
def auto_apply_enabled(config_get: Callable | None = None) -> bool:
|
|
if config_get is None:
|
|
config_get = _default_config_get
|
|
try:
|
|
return bool(config_get("plugins", "auto_apply"))
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _default_config_get(section: str, key: str):
|
|
try:
|
|
from hermes_cli.config import cfg_get, load_config_readonly
|
|
|
|
return cfg_get(load_config_readonly(), section, key, default=None)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def check_due(now: Optional[float] = None, interval_hours: Optional[float] = None,
|
|
config_get: Callable | None = None) -> bool:
|
|
"""The clock gate: last-run marker vs the interval."""
|
|
if interval_hours is None:
|
|
interval_hours = check_interval_hours(config_get)
|
|
if interval_hours <= 0:
|
|
return False
|
|
now = time.time() if now is None else now
|
|
marker = _marker_path()
|
|
try:
|
|
last = marker.stat().st_mtime
|
|
except OSError:
|
|
return True
|
|
return (now - last) >= interval_hours * 3600
|
|
|
|
|
|
#: Minimal singleflight for one home: two gateway ticks overlapping in
|
|
#: this process (boot pass + periodic tick) must not double-fetch or
|
|
#: double-write receipts. A plain non-blocking lock — not a manager.
|
|
_tick_lock = threading.Lock()
|
|
|
|
|
|
def run_scheduled_check(
|
|
*,
|
|
run_checks_fn: Callable[..., list],
|
|
plugins_dir: Path,
|
|
apply_updates_fn: Optional[Callable[[str], None]] = None,
|
|
log=None,
|
|
config_get: Callable | None = None,
|
|
now: Optional[float] = None,
|
|
) -> Optional[list]:
|
|
"""One cadence tick: gate → check → receipt → (opt-in) apply.
|
|
|
|
Returns the check results, or None when not due / disabled / another
|
|
tick is already in flight. NEVER raises — a cadence failure is
|
|
logged, never fatal.
|
|
"""
|
|
import logging
|
|
|
|
if log is None:
|
|
log = logging.getLogger(__name__)
|
|
if not check_due(now=now, config_get=config_get):
|
|
return None
|
|
if not _tick_lock.acquire(blocking=False):
|
|
log.debug("plugin update check already in flight — skipping tick")
|
|
return None
|
|
try:
|
|
return _run_check_locked(
|
|
run_checks_fn=run_checks_fn,
|
|
plugins_dir=plugins_dir,
|
|
apply_updates_fn=apply_updates_fn,
|
|
log=log,
|
|
config_get=config_get,
|
|
)
|
|
finally:
|
|
_tick_lock.release()
|
|
|
|
|
|
def _run_check_locked(
|
|
*,
|
|
run_checks_fn: Callable[..., list],
|
|
plugins_dir: Path,
|
|
apply_updates_fn: Optional[Callable[[str], None]],
|
|
log,
|
|
config_get: Callable | None = None,
|
|
) -> list:
|
|
check_ok = True
|
|
warning = ""
|
|
try:
|
|
results = run_checks_fn(plugins_dir)
|
|
except Exception:
|
|
warning = "plugin update check failed"
|
|
log.warning(warning, exc_info=True)
|
|
results = []
|
|
check_ok = False
|
|
|
|
try:
|
|
from pm import receipt
|
|
token = receipt.begin("plugin-check")
|
|
receipt.record_plugin_checks(results)
|
|
if warning:
|
|
receipt.record_warning(warning)
|
|
updates = [r for r in results if getattr(r, "update_available", None) is True]
|
|
receipt.finalize("failed" if not check_ok else "updates-available" if updates else "ok",
|
|
exit_code=0 if check_ok else 1, token=token)
|
|
except Exception:
|
|
log.debug("plugin-check receipt write failed", exc_info=True)
|
|
|
|
# ONE actionable line (a log, not a system-prompt mutation — cache safe)
|
|
updates = [r for r in results if getattr(r, "update_available", None) is True]
|
|
needs_fixing = [r for r in results if getattr(r, "needs_fixing", None)]
|
|
if updates:
|
|
names = ", ".join(r.name for r in updates)
|
|
log.info(
|
|
"plugin updates available: %s — run `hermes plugins check-updates` "
|
|
"and `hermes plugins update <name>`",
|
|
names,
|
|
)
|
|
if needs_fixing:
|
|
names = ", ".join(r.name for r in needs_fixing)
|
|
log.warning(
|
|
"plugin update_url mismatches need attention: %s — run "
|
|
"`hermes plugins trust-update-url <name>` after review",
|
|
names,
|
|
)
|
|
|
|
# Opt-in unattended apply: git rows ONLY, scan-gated by the update
|
|
# path itself. Pinned/manual/drift/pip are never auto-applied
|
|
# (their update_available is False/None or their class excludes it).
|
|
if apply_updates_fn and updates and auto_apply_enabled(config_get):
|
|
for r in updates:
|
|
if getattr(r, "klass", "") == "git":
|
|
try:
|
|
apply_updates_fn(r.name)
|
|
except (Exception, SystemExit):
|
|
log.warning("auto-apply of %s failed", r.name, exc_info=True)
|
|
|
|
# stamp the marker AFTER a completed run (even a failed one — a
|
|
# failing check retrying every tick would hammer the network)
|
|
try:
|
|
marker = _markers_dir() / "last-run"
|
|
marker.write_text(str(int(time.time())), encoding="utf-8")
|
|
except OSError:
|
|
pass
|
|
return results
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the production caller: real seams for the gateway boot/housekeeping tick
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def maybe_run_gateway_check(
|
|
*,
|
|
run_checks_fn: Optional[Callable[..., list]] = None,
|
|
apply_updates_fn: Optional[Callable[[str], None]] = None,
|
|
plugins_dir: Optional[Path] = None,
|
|
log=None,
|
|
now: Optional[float] = None,
|
|
) -> Optional[list]:
|
|
"""The tick every gateway boot / housekeeping pass calls.
|
|
|
|
Fills in the REAL seams ``run_scheduled_check`` leaves injectable:
|
|
the same read-only ``plugins_updates.run_checks`` the manual
|
|
``hermes plugins check-updates`` uses (urllib feed fetch, git
|
|
ls-remote, PyPI latest), the real plugins dir, and the manual
|
|
``hermes plugins update <name>`` flow as the opt-in apply path —
|
|
auto-apply rides the identical security/consent/scan pipeline.
|
|
|
|
Returns the check results, or None when not due / disabled. Due-gated
|
|
by ``plugins.auto_update_check_hours`` (0 disables); a network error
|
|
costs one warning and a stamped marker — never an apply.
|
|
"""
|
|
if plugins_dir is None:
|
|
from hermes_cli import plugins_cmd
|
|
|
|
plugins_dir = plugins_cmd._plugins_dir()
|
|
if run_checks_fn is None:
|
|
# ONE shared network-default implementation — plugins_updates
|
|
# owns default_fetch / default_ls_remote / _default_pypi_latest;
|
|
# the manual `hermes plugins check-updates` passes the same
|
|
# defaults. No boilerplate re-derivation here.
|
|
from hermes_cli.plugins_updates import run_checks
|
|
|
|
run_checks_fn = run_checks
|
|
if apply_updates_fn is None:
|
|
from hermes_cli import plugins_cmd
|
|
|
|
from functools import partial
|
|
apply_updates_fn = partial(plugins_cmd.cmd_update, interactive=False)
|
|
return run_scheduled_check(
|
|
run_checks_fn=run_checks_fn,
|
|
plugins_dir=plugins_dir,
|
|
apply_updates_fn=apply_updates_fn,
|
|
log=log,
|
|
now=now,
|
|
)
|