124 lines
4.5 KiB
Python
124 lines
4.5 KiB
Python
"""
|
|
Resolve where the Photon sidecar runs from and where its Node deps live.
|
|
|
|
The sidecar source ships inside the installed plugin tree. Hosted/managed
|
|
images keep that tree read-only (``/opt/hermes``, EROFS on every install /
|
|
self-heal path), so resolution mirrors ``resolve_whatsapp_bridge_dir``:
|
|
|
|
1. ``PHOTON_SIDECAR_DIR`` env override — used as-is.
|
|
2. Source dir writable → run in place (dev installs).
|
|
3. Source dir read-only but ``node_modules`` is baked and current → run in
|
|
place (the Dockerfile bakes deps with ``npm ci`` at build time).
|
|
4. Source dir read-only and deps missing/stale → mirror the sidecar source
|
|
files to ``$HERMES_HOME/photon/sidecar`` (durable, writable) and return it.
|
|
|
|
The mirror is refreshed on every resolve by content compare (not mtime);
|
|
``node_modules`` is left alone so the adapter's lockfile-vs-install-marker
|
|
staleness check triggers the ``npm ci`` self-heal inside the mirror.
|
|
|
|
Import-light on purpose: both ``adapter.py`` and ``cli.py`` use it, and
|
|
resolution never happens at import time (it probes/copies on disk).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import filecmp
|
|
import logging
|
|
import os
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar"
|
|
|
|
# Files that define the sidecar; node_modules is deliberately absent (baked
|
|
# on managed images or installed by npm in the mirror).
|
|
_MIRROR_FILES = ("index.mjs", "package.json", "package-lock.json", "patch-spectrum-mixed-attachments.mjs")
|
|
|
|
# Tests monkeypatch these module globals directly; the accessors honor a
|
|
# non-None value and only resolve/derive when unset.
|
|
_SIDECAR_DIR: Optional[Path] = None
|
|
# Written by `hermes photon install-sidecar` on npm failure so
|
|
# check_requirements() can surface the root cause later; cleared on success.
|
|
_NPM_ERROR_LOG: Optional[Path] = None
|
|
_NPM_ERROR_LOG_MAX_CHARS = 300
|
|
|
|
|
|
def dir_writable(path: Path) -> bool:
|
|
"""True when we can create files in ``path`` (probe, not stat — stat lies on
|
|
root-squash / read-only bind mounts)."""
|
|
probe = path / ".hermes-write-probe"
|
|
try:
|
|
probe.touch()
|
|
probe.unlink()
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
_dir_writable = dir_writable
|
|
|
|
|
|
def _lock_newer_than_install(sidecar_dir: Path) -> bool:
|
|
"""True when the committed lockfile postdates npm's install marker
|
|
(``node_modules/.package-lock.json``) — the same signal ``npm ci`` uses.
|
|
False on any stat failure so an odd filesystem never blocks start."""
|
|
lockfile = sidecar_dir / "package-lock.json"
|
|
marker = sidecar_dir / "node_modules" / ".package-lock.json"
|
|
try:
|
|
return lockfile.stat().st_mtime > marker.stat().st_mtime
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
|
|
"""Return the directory the sidecar should run from (see module doc)."""
|
|
source = Path(source_dir) if source_dir is not None else SOURCE_SIDECAR_DIR
|
|
override = os.getenv("PHOTON_SIDECAR_DIR")
|
|
if override:
|
|
return Path(override)
|
|
if _dir_writable(source):
|
|
return source
|
|
# Read-only tree with baked, current deps: run in place (the sidecar never
|
|
# writes inside its own directory).
|
|
if (source / "node_modules").exists() and not _lock_newer_than_install(source):
|
|
return source
|
|
from hermes_constants import get_hermes_home
|
|
mirror = get_hermes_home() / "photon" / "sidecar"
|
|
try:
|
|
mirror.mkdir(parents=True, exist_ok=True)
|
|
for name in _MIRROR_FILES:
|
|
src = source / name
|
|
if not src.exists():
|
|
continue
|
|
dst = mirror / name
|
|
if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False):
|
|
shutil.copy2(str(src), str(dst))
|
|
return mirror
|
|
except OSError as exc:
|
|
logger.warning(
|
|
"[photon] install tree is read-only and mirroring the sidecar "
|
|
"to %s failed (%s) — falling back to the read-only source dir; "
|
|
"dependency installs will not be possible",
|
|
mirror,
|
|
exc,
|
|
)
|
|
return source
|
|
|
|
|
|
def _sidecar_dir() -> Path:
|
|
"""Sidecar runtime dir, resolved once on first use (never at import)."""
|
|
global _SIDECAR_DIR
|
|
if _SIDECAR_DIR is None:
|
|
_SIDECAR_DIR = resolve_sidecar_dir()
|
|
return _SIDECAR_DIR
|
|
|
|
|
|
def _npm_error_log() -> Path:
|
|
"""Path of the persisted npm-failure log (derived from the sidecar dir)."""
|
|
if _NPM_ERROR_LOG is not None:
|
|
return _NPM_ERROR_LOG
|
|
return _sidecar_dir() / ".photon-npm-error.log"
|