Files

409 lines
15 KiB
Python

"""Plugin filesystem layout and manager-owned state."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import shutil
import stat
import tempfile
import uuid
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from .manifest import MANIFEST_FILENAME, PluginManifest, load_manifest_file
logger = logging.getLogger("PluginStorage")
STATE_FILENAME = "state.json"
DEFAULT_PLUGINS_SUBDIR = "plugins"
DEFAULT_SOCKET_NAME = "plugin-manager.sock"
LOG_TAIL_MAX_BYTES = 256 * 1024
@dataclass(frozen=True)
class PluginPaths:
root: Path
plugin_id: str
@property
def plugin_dir(self) -> Path:
return self.root / self.plugin_id
@property
def releases_dir(self) -> Path:
return self.plugin_dir / "releases"
@property
def current_link(self) -> Path:
return self.plugin_dir / "current"
@property
def data_dir(self) -> Path:
return self.plugin_dir / "data"
@property
def logs_dir(self) -> Path:
return self.plugin_dir / "logs"
@property
def log_file(self) -> Path:
return self.logs_dir / "plugin.log"
@property
def state_file(self) -> Path:
return self.plugin_dir / STATE_FILENAME
def release_dir(self, version: str) -> Path:
_assert_safe_segment(version, "version")
return self.releases_dir / version
def venv_dir(self, version: str) -> Path:
return self.release_dir(version) / "venv"
def manifest_path(self, version: Optional[str] = None) -> Path:
if version is None:
return self.current_link / MANIFEST_FILENAME
return self.release_dir(version) / MANIFEST_FILENAME
def _assert_safe_segment(value: str, field: str) -> None:
if not value or value in {".", ".."} or "/" in value or "\\" in value or "\x00" in value:
raise ValueError(f"unsafe {field}: {value!r}")
def safe_join(root: Path, *parts: str) -> Path:
"""Join path parts under root, rejecting traversal."""
root = root.resolve()
candidate = root.joinpath(*parts).resolve()
try:
candidate.relative_to(root)
except ValueError as exc:
raise ValueError(f"path escapes root: {candidate}") from exc
return candidate
def write_release_asset(root: Path, relative: str, data: bytes) -> None:
"""Atomically write an asset without following directory or leaf symlinks.
Walk from the filesystem root with pinned directory descriptors, so a
concurrent symlink substitution cannot redirect extraction. Replacing the
leaf also avoids overwriting another file through an existing hard link.
"""
parts = relative.split("/")
if any(p in {"", ".", ".."} or "\\" in p for p in parts):
raise ValueError("unsafe asset path")
absolute = root.absolute()
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
fd = os.open(absolute.anchor, flags)
temporary = None
try:
for component in (*absolute.parts[1:], *parts[:-1]):
try:
os.mkdir(component, dir_fd=fd)
except FileExistsError:
pass
child = os.open(component, flags, dir_fd=fd)
os.close(fd)
fd = child
try:
mode = os.stat(parts[-1], dir_fd=fd, follow_symlinks=False).st_mode
if not stat.S_ISREG(mode):
raise ValueError("asset destination must be a regular file, not a symlink")
except FileNotFoundError:
pass
temporary = f".asset-{uuid.uuid4().hex}"
out_fd = os.open(
temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644, dir_fd=fd
)
with os.fdopen(out_fd, "wb") as out:
out.write(data)
os.replace(temporary, parts[-1], src_dir_fd=fd, dst_dir_fd=fd)
temporary = None
finally:
if temporary is not None:
os.unlink(temporary, dir_fd=fd)
os.close(fd)
class PluginStorage:
"""Manages plugin directories and manager-owned state.json files."""
def __init__(self, plugins_root: Path | str):
self.root = Path(plugins_root).expanduser().resolve()
def ensure_root(self) -> None:
self.root.mkdir(parents=True, exist_ok=True)
def paths_for(self, plugin_id: str) -> PluginPaths:
_assert_safe_segment(plugin_id, "plugin_id")
if ".." in plugin_id:
raise ValueError(f"unsafe plugin_id: {plugin_id!r}")
return PluginPaths(root=self.root, plugin_id=plugin_id)
def list_plugin_ids(self) -> list[str]:
if not self.root.exists():
return []
ids = []
for child in sorted(self.root.iterdir()):
if child.is_dir() and (child / STATE_FILENAME).is_file():
ids.append(child.name)
return ids
def read_state(self, plugin_id: str) -> Optional[dict[str, Any]]:
path = self.paths_for(plugin_id).state_file
if not path.is_file():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Failed to read state for %s: %s", plugin_id, exc)
return None
if not isinstance(data, dict):
return None
return data
def write_state(self, plugin_id: str, state: dict[str, Any]) -> None:
paths = self.paths_for(plugin_id)
paths.plugin_dir.mkdir(parents=True, exist_ok=True)
# Preserve existing optional fields unless explicitly overwritten.
prev = self.read_state(plugin_id) or {}
payload = {
"id": plugin_id,
"version": str(state.get("version", prev.get("version", ""))),
"enabled": bool(state.get("enabled", prev.get("enabled", False))),
}
# Optional install provenance (catalogue vs local wheel)
source = state.get("source", prev.get("source"))
if source is not None and str(source).strip():
payload["source"] = str(source).strip()
repository = state.get("repository", prev.get("repository"))
if repository is not None and str(repository).strip():
payload["repository"] = str(repository).strip()
self._atomic_write_json(paths.state_file, payload)
def ensure_plugin_layout(self, plugin_id: str, version: str) -> PluginPaths:
paths = self.paths_for(plugin_id)
paths.plugin_dir.mkdir(parents=True, exist_ok=True)
paths.releases_dir.mkdir(parents=True, exist_ok=True)
paths.release_dir(version).mkdir(parents=True, exist_ok=True)
paths.data_dir.mkdir(parents=True, exist_ok=True)
paths.logs_dir.mkdir(parents=True, exist_ok=True)
return paths
def set_current(self, plugin_id: str, version: str) -> None:
paths = self.paths_for(plugin_id)
target = paths.release_dir(version)
if not target.is_dir():
raise FileNotFoundError(f"release directory missing: {target}")
link = paths.current_link
# Relative symlink keeps layout portable
rel = Path("releases") / version
tmp = paths.plugin_dir / f".current.tmp.{os.getpid()}"
if tmp.exists() or tmp.is_symlink():
tmp.unlink()
tmp.symlink_to(rel)
os.replace(tmp, link)
def current_version(self, plugin_id: str) -> Optional[str]:
state = self.read_state(plugin_id)
if state and state.get("version"):
return str(state["version"])
paths = self.paths_for(plugin_id)
if paths.current_link.is_symlink() or paths.current_link.exists():
try:
resolved = paths.current_link.resolve()
return resolved.name
except OSError:
return None
return None
def load_current_manifest(self, plugin_id: str) -> Optional[PluginManifest]:
paths = self.paths_for(plugin_id)
manifest_path = paths.manifest_path()
if not manifest_path.is_file():
version = self.current_version(plugin_id)
if version:
manifest_path = paths.manifest_path(version)
if not manifest_path.is_file():
return None
return load_manifest_file(manifest_path)
def write_manifest(self, plugin_id: str, version: str, manifest: PluginManifest) -> Path:
paths = self.ensure_plugin_layout(plugin_id, version)
dest = paths.manifest_path(version)
self._atomic_write_json(dest, manifest.to_dict())
return dest
def remove_release_code(self, plugin_id: str, keep_data: bool = True) -> None:
paths = self.paths_for(plugin_id)
if paths.current_link.exists() or paths.current_link.is_symlink():
try:
paths.current_link.unlink()
except OSError as exc:
logger.debug("current unlink failed for %s: %s", plugin_id, exc)
if paths.releases_dir.exists():
shutil.rmtree(paths.releases_dir, ignore_errors=True)
if paths.state_file.exists():
try:
paths.state_file.unlink()
except OSError as exc:
logger.debug("state unlink failed for %s: %s", plugin_id, exc)
if not keep_data:
if paths.data_dir.exists():
shutil.rmtree(paths.data_dir, ignore_errors=True)
if paths.logs_dir.exists():
shutil.rmtree(paths.logs_dir, ignore_errors=True)
# Remove plugin dir if empty-ish
if paths.plugin_dir.exists():
try:
remaining = list(paths.plugin_dir.iterdir())
if not remaining:
paths.plugin_dir.rmdir()
else:
shutil.rmtree(paths.plugin_dir, ignore_errors=True)
except OSError:
shutil.rmtree(paths.plugin_dir, ignore_errors=True)
def config_path(self, plugin_id: str) -> Path:
"""Path to the plugin-owned config.json under data/."""
return self.paths_for(plugin_id).data_dir / "config.json"
def runtime_path(self, plugin_id: str) -> Path:
"""Path to the plugin runtime snapshot under data/."""
return self.paths_for(plugin_id).data_dir / "runtime.json"
def read_config(self, plugin_id: str) -> dict[str, Any]:
"""Read plugin-owned data/config.json. Missing file → {}."""
path = self.config_path(plugin_id)
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid plugin config.json: {exc}") from exc
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("plugin config.json must be a JSON object")
return data
def write_config(self, plugin_id: str, config: dict[str, Any]) -> Path:
"""Atomically write plugin-owned data/config.json (opaque object)."""
if not isinstance(config, dict):
raise ValueError("config must be a JSON object")
paths = self.paths_for(plugin_id)
paths.data_dir.mkdir(parents=True, exist_ok=True)
path = self.config_path(plugin_id)
self._atomic_write_json(path, config)
return path
def read_runtime(self, plugin_id: str) -> dict[str, Any]:
"""Read plugin-owned data/runtime.json. Missing file -> {}."""
path = self.runtime_path(plugin_id)
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"invalid plugin runtime.json: {exc}") from exc
if data is None:
return {}
if not isinstance(data, dict):
raise ValueError("plugin runtime.json must be a JSON object")
return data
def tail_log(self, plugin_id: str, lines: int = 200) -> list[str]:
paths = self.paths_for(plugin_id)
log_path = paths.log_file
if not log_path.is_file():
return []
lines = max(1, min(int(lines), 5000))
try:
with log_path.open("rb") as source:
source.seek(0, os.SEEK_END)
source.seek(max(0, source.tell() - LOG_TAIL_MAX_BYTES))
data = source.read(LOG_TAIL_MAX_BYTES).decode("utf-8", errors="replace")
except OSError:
return []
all_lines = data.splitlines()
return all_lines[-lines:]
@staticmethod
def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=".tmp-", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh, indent=2, sort_keys=True)
fh.write("\n")
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp_name, path)
finally:
if os.path.exists(tmp_name):
try:
os.unlink(tmp_name)
except OSError:
pass
def resolve_plugins_root(
config: Optional[dict[str, Any]] = None,
*,
storage_dir: Optional[Path | str] = None,
) -> Path:
"""Resolve plugins root from config or storage_dir."""
config = config or {}
plugins_cfg = config.get("plugins") if isinstance(config.get("plugins"), dict) else {}
configured = plugins_cfg.get("root") or plugins_cfg.get("plugins_dir")
if configured:
return Path(str(configured)).expanduser().resolve()
if storage_dir is None:
from repeater.config import resolve_storage_dir
storage_dir = resolve_storage_dir(config)
return Path(storage_dir).expanduser().resolve() / DEFAULT_PLUGINS_SUBDIR
def resolve_plugin_socket_path(
config: Optional[dict[str, Any]] = None,
*,
storage_dir: Optional[Path | str] = None,
) -> Path:
config = config or {}
plugins_cfg = config.get("plugins") if isinstance(config.get("plugins"), dict) else {}
configured = plugins_cfg.get("socket") or plugins_cfg.get("socket_path")
if configured:
candidate = Path(str(configured)).expanduser().resolve()
if len(str(candidate)) <= 92:
return candidate
digest = hashlib.sha256(candidate.as_posix().encode("utf-8")).hexdigest()[:16]
return Path(tempfile.gettempdir()) / f"openhop-plugin-{digest}.sock"
if storage_dir is None:
from repeater.config import resolve_storage_dir
storage_dir = resolve_storage_dir(config)
candidate = Path(storage_dir).expanduser().resolve() / DEFAULT_SOCKET_NAME
if len(str(candidate)) <= 92:
return candidate
digest = hashlib.sha256(candidate.as_posix().encode("utf-8")).hexdigest()[:16]
return Path(tempfile.gettempdir()) / f"openhop-plugin-{digest}.sock"
def resolve_catalogue_url(config: Optional[dict] = None) -> str:
"""Return configured catalogue URL or the built-in default."""
from .catalogue import DEFAULT_CATALOGUE_URL
config = config or {}
plugins_cfg = config.get("plugins") if isinstance(config, dict) else None
if not isinstance(plugins_cfg, dict):
return DEFAULT_CATALOGUE_URL
raw = plugins_cfg.get("catalogue_url") or plugins_cfg.get("catalogue")
if isinstance(raw, str) and raw.strip():
return raw.strip()
return DEFAULT_CATALOGUE_URL