diff --git a/docs/plugins/index.yaml.example b/docs/plugins/index.yaml.example deleted file mode 100644 index 839efae..0000000 --- a/docs/plugins/index.yaml.example +++ /dev/null @@ -1,113 +0,0 @@ -# steward-plugins / index.yaml -# -# This file is the catalog index for the steward plugin repository. -# It is fetched by the app's Settings → Plugins → Plugin Catalog UI. -# -# Steward reads this file from: -# https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml -# -# After adding or updating a plugin entry, commit and push — the change is -# live immediately for anyone whose app fetches the catalog (cache TTL: 5 min). -# -# REQUIRED fields for each plugin entry: -# name - must match the plugin's directory name exactly -# version - semver string -# download_url - direct URL to a zip file containing the plugin -# checksum_sha256 - SHA-256 hex digest of the zip (leave empty to skip verification) -# -# OPTIONAL fields: -# description, author, license, min_app_version, -# repository_url, homepage, tags -# -# Generating a checksum: -# sha256sum traefik.zip -# shasum -a 256 traefik.zip (macOS) -# -# Download URL conventions: -# Gitea release assets (recommended): -# https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip -# Upload the zip as a release attachment in Gitea; paste the URL here. -# Source archive tarballs work too but release assets are preferred (smaller, plugin-only). - -version: 1 -updated: "2026-03-22" - -plugins: - - - name: http - version: "1.0.0" - description: "Synthetic HTTP endpoint monitoring — status code, response time, content match, TLS expiry" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/http" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/http-v1.0.0/http.zip" - checksum_sha256: "" - tags: - - monitoring - - http - - synthetic - - uptime - - - name: docker - version: "1.0.0" - description: "Docker container status, resource usage, and restart tracking via Docker socket" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v1.0.0/docker.zip" - checksum_sha256: "" - tags: - - containers - - docker - - infrastructure - - - name: traefik - version: "1.0.0" - description: "Traefik reverse proxy metrics and access log integration" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/traefik" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip" - checksum_sha256: "" # fill in after running: sha256sum traefik.zip - tags: - - proxy - - metrics - - access-log - - - name: unifi - version: "1.0.0" - description: "UniFi Network controller integration — WAN health, devices, clients, DPI" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/unifi" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/unifi-v1.0.0/unifi.zip" - checksum_sha256: "" - tags: - - network - - unifi - - ubiquiti - - - name: host_agent - version: "1.0.0" - description: "Remote Linux host resource monitoring via a lightweight Python push agent" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/host_agent" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/host_agent-v1.0.0/host_agent.zip" - checksum_sha256: "" - tags: - - host - - monitoring - - cpu - - memory - - storage diff --git a/docs/plugins/writing-a-plugin.md b/docs/plugins/writing-a-plugin.md index 19d5348..6089cc4 100644 --- a/docs/plugins/writing-a-plugin.md +++ b/docs/plugins/writing-a-plugin.md @@ -28,7 +28,7 @@ license: "MIT" # any SPDX identifier, e.g. MIT, Apache-2.0, GPL-3.0 # Optional: prevents loading on older app versions min_app_version: "0.1.0" -# Optional: shown in the catalog UI +# Optional: where the plugin's source lives repository_url: "https://github.com/yourname/yourrepo" homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin" tags: @@ -393,83 +393,12 @@ async def mcp_overview(db) -> list[dict]: --- -## Publishing to the Catalog +## Shipping a Plugin -The official plugin catalog is hosted at `https://git.fabledsword.com/bvandeusen/Steward-plugins`. Anyone can submit a plugin by opening a pull request — first-party and third-party plugins are treated identically by the catalog system. +Steward never downloads plugins. There is no catalog and no install button: the app runs with no outbound network. -### Repo layout - -``` -steward-plugins/ -├── index.yaml ← catalog index — the only file Steward fetches -├── myplugin/ -│ ├── plugin.yaml -│ ├── __init__.py -│ └── ... -└── .github/ - └── workflows/ - └── publish.yml ← packages each plugin dir into a zip on release -``` - -### index.yaml entry - -Each plugin needs a corresponding entry in `index.yaml`. See `docs/plugins/index.yaml.example` for the full schema. The minimum required fields are: - -```yaml -- name: myplugin - version: "1.0.0" - description: "What this plugin does" - author: "Your Name" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://github.com/yourname/yourrepo" - homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin" - download_url: "https://github.com/yourname/yourrepo/releases/download/myplugin-v1.0.0/myplugin.zip" - checksum_sha256: "" # fill in after zipping — see below - tags: - - monitoring -``` - -### Creating a release zip - -The zip must contain the plugin files at the top level OR inside a single directory named after the plugin. Both layouts work: - -``` -# Layout A — flat (preferred) -myplugin.zip -├── plugin.yaml -├── __init__.py -└── ... - -# Layout B — single top-level directory (also accepted, GitHub archive default) -myplugin.zip -└── myplugin/ - ├── plugin.yaml - └── ... -``` - -Generate the zip and its checksum: - -```bash -cd steward-plugins -zip -r myplugin.zip myplugin/ -sha256sum myplugin.zip # paste this into index.yaml checksum_sha256 -``` - -Upload `myplugin.zip` as a GitHub release asset, then update `index.yaml` with the release download URL and checksum. - -### How install works - -When a user clicks **Install** in Settings → Plugins: - -1. The app downloads the zip from `download_url` -2. Verifies the SHA-256 checksum (if provided) -3. Extracts the plugin into its `PLUGIN_DIR` -4. Runs any pending Alembic migrations for the plugin -5. Attempts a **hot-reload** — registers the blueprint and scheduled tasks without restarting -6. If the plugin was previously loaded (blueprint already mounted), a **restart** is required to pick up the new code - -Hot-reload works reliably for brand-new plugin installs. Updates to already-active plugins require a restart, which can be triggered from the same settings page. +- **First-party plugins** live in this repository under `plugins/` and ship inside the image, so they version with core. +- **A third-party plugin** is a folder you put in the external plugin root, `/data/plugins` by default (`STEWARD_PLUGIN_DIR` moves it). Restart Steward and the plugin appears in Settings → Plugins, where you enable it. A bundled plugin of the same name wins over an external one. --- @@ -486,4 +415,3 @@ Hot-reload works reliably for brand-new plugin installs. Updates to already-acti - [ ] MCP tools (if any) registered via `register_mcp_tools(register)` with a role and `read_only` - [ ] Templates namespaced under `templates/myplugin/` - [ ] Plugin enabled in app settings -- [ ] `index.yaml` entry added with `download_url` and `checksum_sha256` diff --git a/plugins/index.yaml b/plugins/index.yaml deleted file mode 100644 index 3d5fe8e..0000000 --- a/plugins/index.yaml +++ /dev/null @@ -1,74 +0,0 @@ -# steward-plugins / index.yaml -# -# Plugin catalog for Steward. -# Fetched by the app at: Settings → Plugins → Plugin Catalog -# -# After updating an entry, commit and push — changes are live within 5 minutes -# (the app caches this file in-process for 300 seconds). -# -# See docs/plugins/index.yaml.example in the main app repo for the full schema. - -version: 1 -updated: "2026-03-22" - -plugins: - - - name: docker - version: "2.0.0" - description: "Per-host Docker container status + resource usage, collected by the Steward host agent" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/docker" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/docker-v2.0.0/docker.zip" - checksum_sha256: "" - tags: - - containers - - docker - - infrastructure - - - name: traefik - version: "1.0.0" - description: "Traefik reverse proxy metrics and access log integration" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/traefik" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/traefik-v1.0.0/traefik.zip" - checksum_sha256: "" - tags: - - proxy - - metrics - - access-log - - - name: unifi - version: "1.0.0" - description: "UniFi Network controller integration — WAN health, devices, clients, DPI" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/unifi" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/unifi-v1.0.0/unifi.zip" - checksum_sha256: "" - tags: - - network - - unifi - - ubiquiti - - - name: ups - version: "1.0.0" - description: "UPS monitoring via NUT (Network UPS Tools) with Ansible shutdown automation" - author: "Steward" - license: "MIT" - min_app_version: "0.1.0" - repository_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins" - homepage: "https://git.fabledsword.com/bvandeusen/Steward-plugins/src/branch/main/ups" - download_url: "https://git.fabledsword.com/bvandeusen/Steward-plugins/releases/download/ups-v1.0.0/ups.zip" - checksum_sha256: "" - tags: - - ups - - power - - nut diff --git a/steward/app.py b/steward/app.py index 514b9c1..60e6593 100644 --- a/steward/app.py +++ b/steward/app.py @@ -28,14 +28,12 @@ def create_app( "secret_key": "test-secret-key", "db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS, "plugin_dirs": ["plugins"], - "plugin_install_dir": "plugins", } app.config.update( SECRET_KEY=bootstrap["secret_key"], DATABASE_URL=bootstrap["database_url"], PLUGIN_DIRS=bootstrap["plugin_dirs"], - PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"], TESTING=testing, # Lax keeps the session cookie off cross-site POSTs and iframes; the # CSRF token below is the check that does not depend on the browser. @@ -477,26 +475,6 @@ def _register_core_tasks(app: Quart) -> None: ) ) - # Fetch the plugin catalog from the configured repositories as each copy - # falls due (plugin_index.refresh_due), so Settings → Plugins only reads - # what is stored (Background Jobs ruling, 2026-10-08). The repositories are - # read on every tick, so an edit in Settings is fetched within a minute. - async def run_catalog_refresh(): - from .core.plugin_index import refresh_due, repo_urls - from .core.settings import get_all_settings - async with app.db_sessionmaker() as db: - settings = await get_all_settings(db) - await refresh_due(repo_urls(settings)) - - app._task_registry.append( - ScheduledTask( - name="plugin_catalog_refresh", - coro_factory=run_catalog_refresh, - interval_seconds=60, - run_on_startup=True, - ) - ) - # Fire due Ansible schedules (recurring playbook runs). Checks every minute; # each schedule's own interval gates whether it actually runs. async def run_ansible_schedules(): diff --git a/steward/config.py b/steward/config.py index b177b7f..3e3faa3 100644 --- a/steward/config.py +++ b/steward/config.py @@ -55,7 +55,7 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]: # • bundled — first-party plugins shipped inside the image at repo-root # `plugins/`; they version atomically with core and are read-only at runtime. # • external — operator-mounted dir for third-party plugins, persisted in the - # /data volume. Downloads/installs land here, never in the bundled dir. + # /data volume. Steward never downloads plugins; the operator puts them here. # Bundled is scanned first, so on a name collision the first-party plugin wins. bundled_plugin_dir = raw.get("plugin_dir", "plugins") external_plugin_dir = ( @@ -72,8 +72,6 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]: "secret_key": secret_key, "db_connect_timeout": db_connect_timeout, "plugin_dirs": plugin_dirs, - # Installs/downloads target the external (writable, persistent) dir. - "plugin_install_dir": external_plugin_dir or bundled_plugin_dir, } diff --git a/steward/core/plugin_index.py b/steward/core/plugin_index.py deleted file mode 100644 index d7de14a..0000000 --- a/steward/core/plugin_index.py +++ /dev/null @@ -1,135 +0,0 @@ -# steward/core/plugin_index.py -"""Remote plugin catalog: fetch, parse, and cache the plugin index.yaml.""" -from __future__ import annotations -import logging -import time -from typing import Any - -logger = logging.getLogger(__name__) - -# The catalog is fetched by the scheduled plugin_catalog_refresh task, never by -# a page load (Background Jobs ruling, 2026-10-08): a repository's copy is -# fetched when it has none or it is older than CATALOG_MAX_AGE, and a repository -# that failed is tried again after RETRY_AFTER. Refresh on Settings → Plugins and -# installing a plugin fetch at once, because the operator asked. -CATALOG_MAX_AGE = 3600 # seconds -RETRY_AFTER = 300 # seconds -# url → (raw index, fetched_at monotonic); url → failed_at monotonic -_url_cache: dict[str, tuple[dict, float]] = {} -_failed_at: dict[str, float] = {} - - -class CatalogPlugin: - """One entry from the remote plugin index.""" - - __slots__ = ( - "name", "version", "description", "author", - "min_app_version", "download_url", "checksum_sha256", - "repository_url", "homepage", "license", "tags", - ) - - def __init__(self, data: dict[str, Any]) -> None: - self.name: str = data.get("name", "") - self.version: str = data.get("version", "0.0.0") - self.description: str = data.get("description", "") - self.author: str = data.get("author", "") - self.min_app_version: str | None = data.get("min_app_version") - self.download_url: str = data.get("download_url", "") - self.checksum_sha256: str = data.get("checksum_sha256", "") - self.repository_url: str = data.get("repository_url", "") - self.homepage: str = data.get("homepage", "") - self.license: str = data.get("license", "") - self.tags: list[str] = data.get("tags", []) - - -def plugin_repos(settings: dict) -> list[dict]: - """The configured plugin repositories, [{"name", "url"}], from settings. - - Migrates the legacy plugins.index_url single-URL field to the list format - on first access if plugins.repositories is not yet set. - """ - repos = settings.get("plugins.repositories") - if repos and isinstance(repos, list): - return repos - legacy = settings.get("plugins.index_url", "").strip() - if legacy: - return [{"name": "Default", "url": legacy}] - return [] - - -def repo_urls(settings: dict) -> list[str]: - return [r["url"].strip() for r in plugin_repos(settings) if (r.get("url") or "").strip()] - - -def _plugins(raw: dict) -> list[CatalogPlugin]: - return [CatalogPlugin(p) for p in raw.get("plugins", [])] - - -async def _fetch_one(url: str) -> bool: - """Fetch one index URL into the cache. True when it was fetched.""" - import httpx - import yaml - - try: - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get(url) - resp.raise_for_status() - raw = yaml.safe_load(resp.text) or {} - except Exception as exc: - # The tick retries every RETRY_AFTER seconds, so a repository that - # answers 404 or is unreachable is one line each time, not a traceback. - logger.warning("Plugin catalog not fetched from %s: %s", url, exc) - _failed_at[url] = time.monotonic() - return False - _url_cache[url] = (raw, time.monotonic()) - _failed_at.pop(url, None) - logger.info("Plugin catalog fetched from %s: %d plugin(s)", url, len(raw.get("plugins", []))) - return True - - -def _merge(urls: list[str]) -> list[CatalogPlugin]: - """Plugins from the cached copies, deduplicated by name: the first - repository in the list wins.""" - seen: dict[str, CatalogPlugin] = {} - for url in urls: - if url in _url_cache: - for plugin in _plugins(_url_cache[url][0]): - seen.setdefault(plugin.name, plugin) - return list(seen.values()) - - -def cached_catalog(urls: list[str]) -> tuple[list[CatalogPlugin], list[str]]: - """(the merged catalog from what is cached, the URLs with no copy yet). - Reads the cache only: this is what a page shows.""" - return _merge(urls), [u for u in urls if u not in _url_cache] - - -async def fetch_catalog(urls: list[str]) -> list[CatalogPlugin]: - """Fetch every URL now, then the merged catalog (a failed URL keeps its - last copy). For an operator's Refresh or install, not for a page load.""" - for url in urls: - await _fetch_one(url) - return _merge(urls) - - -async def refresh_due(urls: list[str], now: float | None = None) -> int: - """The scheduled task's tick: fetch each URL with no copy, or one older - than CATALOG_MAX_AGE, unless it failed within RETRY_AFTER. Returns how - many were fetched.""" - now = time.monotonic() if now is None else now - fetched = 0 - for url in urls: - cached = _url_cache.get(url) - if cached and now - cached[1] < CATALOG_MAX_AGE: - continue - if url in _failed_at and now - _failed_at[url] < RETRY_AFTER: - continue - fetched += await _fetch_one(url) - return fetched - - -def clear_catalog_cache() -> None: - """Forget every cached copy and failure, so the next tick of the scheduled - task fetches the repositories again (after they are edited).""" - _url_cache.clear() - _failed_at.clear() diff --git a/steward/core/plugin_manager.py b/steward/core/plugin_manager.py index 42023e2..d2fe820 100644 --- a/steward/core/plugin_manager.py +++ b/steward/core/plugin_manager.py @@ -1,11 +1,8 @@ # steward/core/plugin_manager.py from __future__ import annotations -import hashlib import logging import os -import shutil import sys -import zipfile from pathlib import Path from typing import TYPE_CHECKING @@ -290,184 +287,8 @@ def load_plugins(app: "Quart") -> None: logger.info("Plugin %r loaded (v%s)", name, meta.get("version", "?")) -# Bounds on what an install may bring in: the download itself, and what it -# unpacks to (a small zip can expand to fill the disk). -MAX_PLUGIN_DOWNLOAD_BYTES = 50 * 1024 * 1024 -MAX_PLUGIN_UNPACKED_BYTES = 200 * 1024 * 1024 - - -class PluginInstallError(Exception): - """An install refused or failed; the message is shown to the admin.""" - - -def check_plugin_name(name: str) -> None: - """A plugin is imported by its folder name, so the name must be a plain - Python identifier — which also keeps it from naming a path.""" - if not (name.isascii() and name.isidentifier()): - raise PluginInstallError(f"{name!r} is not a valid plugin name") - - -def verify_checksum(content: bytes, expected_sha256: str) -> None: - """Refuse a download whose sha256 is missing from the catalog or differs.""" - if not expected_sha256: - raise PluginInstallError( - "The catalog lists no checksum for this plugin, so Steward cannot " - "tell it is the file the catalog meant. Not installed.") - actual = hashlib.sha256(content).hexdigest() - if actual != expected_sha256.strip().lower(): - raise PluginInstallError(f"Checksum mismatch: expected {expected_sha256}, got {actual}") - - -def extract_plugin(content: bytes, name: str, dest: Path) -> None: - """Unpack a plugin zip into `dest`, which must not exist yet. - - One top-level folder whose name starts with the plugin name is stripped: - name/ (clean plugin zip) - name-v1.0.0/ (release tag archive) - otherwise the zip is unpacked as it stands. Every member must land inside - `dest` — an absolute path or a `..` that climbs out refuses the whole zip — - and the unpacked total is capped. - """ - import io - root = dest.resolve() - with zipfile.ZipFile(io.BytesIO(content)) as zf: - infos = zf.infolist() - if sum(i.file_size for i in infos) > MAX_PLUGIN_UNPACKED_BYTES: - raise PluginInstallError( - f"Unpacks to more than {MAX_PLUGIN_UNPACKED_BYTES // (1024 * 1024)} MB") - top_level = {i.filename.split("/")[0] for i in infos} - prefix = "" - if len(top_level) == 1: - candidate = next(iter(top_level)) - if candidate.lower().startswith(name.lower()) and any( - i.filename.startswith(candidate + "/") for i in infos): - prefix = candidate + "/" - planned: list[tuple[zipfile.ZipInfo, Path]] = [] - for info in infos: - if prefix and not info.filename.startswith(prefix): - continue - rel = info.filename[len(prefix):] - if not rel: - continue - target = (root / rel).resolve() - if not target.is_relative_to(root) or target == root: - raise PluginInstallError(f"Refused: {info.filename!r} points outside the plugin folder") - planned.append((info, target)) - root.mkdir(parents=True) - for info, target in planned: - if info.is_dir(): - target.mkdir(parents=True, exist_ok=True) - continue - target.parent.mkdir(parents=True, exist_ok=True) - with zf.open(info) as src, open(target, "wb") as out: - shutil.copyfileobj(src, out) - - -async def _download(url: str) -> bytes: - import httpx - chunks: list[bytes] = [] - size = 0 - async with httpx.AsyncClient(timeout=60, follow_redirects=True) as client: - async with client.stream("GET", url) as resp: - resp.raise_for_status() - async for chunk in resp.aiter_bytes(): - size += len(chunk) - if size > MAX_PLUGIN_DOWNLOAD_BYTES: - raise PluginInstallError( - f"Download is larger than {MAX_PLUGIN_DOWNLOAD_BYTES // (1024 * 1024)} MB") - chunks.append(chunk) - return b"".join(chunks) - - -async def download_and_install_plugin( - app: "Quart", - name: str, - download_url: str, - expected_sha256: str, -) -> tuple[bool, str]: - """Download a plugin zip named by the catalog, verify its checksum, and - unpack it into the install dir, replacing any earlier copy whole. - - The URL and checksum must come from the catalog entry, never from the - request. Returns (success, message). Does NOT hot-reload — call - hot_reload_plugin() after this to activate a new plugin without a restart. - """ - # Downloads always land in the external (writable, persistent) install dir, - # never the read-only bundled dir. - plugin_dir = Path(app.config["PLUGIN_INSTALL_DIR"]).resolve() - - # Ensure the install dir is on sys.path (may not be set yet if no plugins - # were enabled at startup) - plugin_dir_str = str(plugin_dir) - if plugin_dir_str not in sys.path: - sys.path.insert(0, plugin_dir_str) - - try: - check_plugin_name(name) - if not download_url: - raise PluginInstallError("The catalog lists no download URL for this plugin") - logger.info("Downloading plugin %r from %s", name, download_url) - try: - content = await _download(download_url) - except PluginInstallError: - raise - except Exception as exc: - raise PluginInstallError(f"Download failed: {exc}") from exc - verify_checksum(content, expected_sha256) - - # Unpack beside the live copy, then swap: a failed or refused zip - # leaves the installed plugin untouched, and an update drops files the - # new version no longer ships. - plugin_dir.mkdir(parents=True, exist_ok=True) - dest = plugin_dir / name - staging = plugin_dir / f".{name}.installing" - old = plugin_dir / f".{name}.replaced" - for leftover in (staging, old): - shutil.rmtree(leftover, ignore_errors=True) - try: - extract_plugin(content, name, staging) - except PluginInstallError: - raise - except Exception as exc: - raise PluginInstallError(f"Extraction failed: {exc}") from exc - try: - if dest.exists(): - dest.rename(old) - staging.rename(dest) - except OSError as exc: - if old.exists() and not dest.exists(): - old.rename(dest) # put the previous version back - raise PluginInstallError(f"Could not move the plugin into place: {exc}") from exc - shutil.rmtree(old, ignore_errors=True) - except PluginInstallError as exc: - shutil.rmtree(plugin_dir / f".{name}.installing", ignore_errors=True) - logger.error("Plugin %r: %s", name, exc) - return False, str(exc) - - # Run migrations for the newly installed plugin. - # Include all plugin migration dirs so Alembic can resolve any previously-stamped - # revisions from other plugins already in alembic_version. - try: - mdir = plugin_dir / name / "migrations" - if mdir.exists(): - from steward.core.migration_runner import ( - run_plugin_migrations, - discover_all_in, - ) - # Span every plugin root so Alembic can resolve bundled-plugin - # revisions already stamped in alembic_version. - all_dirs = discover_all_in([Path(d).resolve() for d in app.config["PLUGIN_DIRS"]]) - run_plugin_migrations(app.config["DATABASE_URL"], all_dirs) - except Exception: - logger.exception("Plugin %r: migration failed after install", name) - return False, "Plugin extracted but migrations failed — check logs" - - logger.info("Plugin %r installed successfully", name) - return True, "Installed" - - def hot_reload_plugin(app: "Quart", name: str) -> tuple[bool, str]: - """Activate a newly installed plugin without restarting the app. + """Activate a plugin that was just enabled without restarting the app. This works for plugins that were NOT previously loaded. For plugins that are already registered (blueprint already mounted), a full restart is diff --git a/steward/core/settings.py b/steward/core/settings.py index 75f4098..06a7cf4 100644 --- a/steward/core/settings.py +++ b/steward/core/settings.py @@ -118,7 +118,6 @@ DEFAULTS: dict[str, Any] = { # the agent's ~30s cadence, then roll up to hourly averages kept much longer. "metrics.retention.raw_days": 7, "metrics.retention.rollup_days": 90, - "plugins.index_url": "https://git.fabledsword.com/bvandeusen/Steward-plugins/raw/branch/main/index.yaml", # Default-enabled plugins. These are the generic, non-vendor-specific # bundled plugins (protocols/standards, not a single product) — useful on # almost any install, so a fresh deployment comes up monitoring rather than diff --git a/steward/migrations/versions/0035_drop_plugin_catalog_settings.py b/steward/migrations/versions/0035_drop_plugin_catalog_settings.py new file mode 100644 index 0000000..905d6e9 --- /dev/null +++ b/steward/migrations/versions/0035_drop_plugin_catalog_settings.py @@ -0,0 +1,29 @@ +"""Drop the plugin catalog's stored settings + +Steward no longer has a plugin catalog (#5374): first-party plugins ship in the +image and a third-party plugin is a folder the operator mounts, so nothing reads +`plugins.index_url` or `plugins.repositories` any more. The removed feature +takes its rows with it, as 0025 did for the http plugin. + +Revision ID: 0035_drop_plugin_catalog_settings +Revises: 0034_user_time_zone +Create Date: 2026-10-08 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa + +revision: str = "0035_drop_plugin_catalog_settings" +down_revision: Union[str, None] = "0034_user_time_zone" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute(sa.text( + "DELETE FROM app_settings WHERE key IN ('plugins.index_url', 'plugins.repositories')")) + + +def downgrade() -> None: + # Deliberately empty: nothing would read the rows. + pass diff --git a/steward/settings/routes.py b/steward/settings/routes.py index 424fa0c..95445ea 100644 --- a/steward/settings/routes.py +++ b/steward/settings/routes.py @@ -7,7 +7,6 @@ from quart import Blueprint, current_app, render_template, request, redirect, ur from steward.auth.middleware import require_role from steward.core.audit import audit_request, log_audit -from steward.core.plugin_index import plugin_repos, repo_urls from steward.core.logging_setup import LEVELS as LOG_LEVELS, set_log_level from steward.models.users import UserRole from steward.core.settings import ( @@ -605,17 +604,6 @@ async def send_report_now(): ) -# ── Plugin repository helpers ───────────────────────────────────────────────── - -async def _save_plugin_repos(db, repos: list[dict]) -> None: - await set_setting(db, "plugins.repositories", repos) - - -async def _plugin_repos_partial(settings: dict): - repos = plugin_repos(settings) - return await render_template("settings/_plugin_repos.html", repos=repos) - - def _build_plugin_cfg_from_form(plugin: dict, form) -> dict: """Extract config values for one plugin from a form submission.""" name = plugin["_dir"] @@ -678,11 +666,10 @@ async def plugins(): for p in discovered: p["_kind"] = p.get("kind") or ( "capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration") - repos = plugin_repos(settings) # Config left behind by plugins that are no longer installed. Surfaced for an # explicit operator decision rather than cleaned up automatically: an external - # plugin can be missing because /data/plugins isn't mounted or an install - # failed, and silently dropping its row would destroy stored credentials. + # plugin can be missing because /data/plugins isn't mounted, and silently + # dropping its row would destroy stored credentials. # # Load failures are folded in so every plugin counted by the admin banner has # a row here. A plugin that failed BECAUSE it has no directory is not in @@ -700,7 +687,6 @@ async def plugins(): integrations=[p for p in discovered if p["_kind"] != "capability"], discovered_plugins=discovered, orphaned_plugins=orphans, - repos=repos, settings=settings, ) @@ -723,8 +709,6 @@ async def plugin_orphan_remove(name: str): if removed: await _reload_app_config() - from steward.core.plugin_index import clear_catalog_cache - clear_catalog_cache() await log_audit( current_app, session.get("user_id"), session.get("username", ""), "plugin.settings_removed", entity_type="plugin", entity_id=name) @@ -772,8 +756,6 @@ async def save_plugin_detail(name: str): await set_setting(db, f"plugin.{name}", plugin_cfg) await _reload_app_config() - from steward.core.plugin_index import clear_catalog_cache - clear_catalog_cache() new_enabled = plugin_cfg.get("enabled", False) if old_enabled != new_enabled: @@ -787,186 +769,6 @@ async def save_plugin_detail(name: str): return redirect(url_for("settings.plugin_detail", name=name)) -# ── Plugin repository HTMX routes ───────────────────────────────────────────── - -@settings_bp.post("/plugins/repos/add") -@require_role(UserRole.admin) -async def plugin_repos_add(): - form = await request.form - async with current_app.db_sessionmaker() as db: - settings = await get_all_settings(db) - repos = plugin_repos(settings) - url = form.get("url", "").strip() - repo_name = form.get("name", "").strip() or url - if url: - repos.append({"name": repo_name, "url": url}) - async with current_app.db_sessionmaker() as db: - async with db.begin(): - await _save_plugin_repos(db, repos) - from steward.core.plugin_index import clear_catalog_cache - clear_catalog_cache() - return await _plugin_repos_partial({"plugins.repositories": repos}) - - -@settings_bp.post("/plugins/repos//remove") -@require_role(UserRole.admin) -async def plugin_repos_remove(idx: int): - async with current_app.db_sessionmaker() as db: - settings = await get_all_settings(db) - repos = plugin_repos(settings) - if 0 <= idx < len(repos): - repos.pop(idx) - async with current_app.db_sessionmaker() as db: - async with db.begin(): - await _save_plugin_repos(db, repos) - from steward.core.plugin_index import clear_catalog_cache - clear_catalog_cache() - return await _plugin_repos_partial({"plugins.repositories": repos}) - - -@settings_bp.post("/plugins/repos//save") -@require_role(UserRole.admin) -async def plugin_repos_save(idx: int): - form = await request.form - async with current_app.db_sessionmaker() as db: - settings = await get_all_settings(db) - repos = plugin_repos(settings) - if 0 <= idx < len(repos): - repos[idx] = { - "name": form.get("name", "").strip() or repos[idx]["name"], - "url": form.get("url", "").strip() or repos[idx]["url"], - } - async with current_app.db_sessionmaker() as db: - async with db.begin(): - await _save_plugin_repos(db, repos) - from steward.core.plugin_index import clear_catalog_cache - clear_catalog_cache() - return await _plugin_repos_partial({"plugins.repositories": repos}) - - -# ── Plugin catalog (HTMX partial) ───────────────────────────────────────────── - -@settings_bp.get("/plugins/catalog/") -@require_role(UserRole.admin) -async def plugins_catalog(): - """HTMX partial: the plugin catalog as the scheduled plugin_catalog_refresh - task last fetched it. A page load never reaches a repository (Background - Jobs ruling, 2026-10-08); Refresh does, because the operator asked.""" - return await _catalog_partial(fetch=False) - - -@settings_bp.post("/plugins/catalog/refresh") -@require_role(UserRole.admin) -async def plugins_catalog_refresh(): - """HTMX partial: fetch every configured repository now, then the catalog.""" - return await _catalog_partial(fetch=True) - - -async def _catalog_partial(fetch: bool): - from steward.core.plugin_index import cached_catalog, fetch_catalog - from steward.core.plugin_manager import _LOADED_PLUGINS - - async with current_app.db_sessionmaker() as db: - settings = await get_all_settings(db) - urls = repo_urls(settings) - if not urls: - return await render_template( - "settings/_catalog_partial.html", - catalog=[], installed_names=set(), installed_versions={}, loaded_names=set(), - error="No plugin repositories configured.", - ) - - if fetch: - await fetch_catalog(urls) - catalog, missing = cached_catalog(urls) - error = None - if missing: - error = (f"Not fetched yet: {', '.join(missing)}. Steward fetches each repository within a " - "minute of starting or of a change here, then hourly; Refresh fetches now.") - elif not catalog: - error = "The configured repositories list no plugins." - - discovered = _discover_plugins() - return await render_template( - "settings/_catalog_partial.html", - catalog=catalog, - installed_names={p["_dir"] for p in discovered}, - installed_versions={p["_dir"]: p.get("version", "0.0.0") for p in discovered}, - loaded_names=_LOADED_PLUGINS, - error=error, - ) - - -# ── Plugin install ───────────────────────────────────────────────────────────── - -@settings_bp.post("/plugins/install/") -@require_role(UserRole.admin) -async def install_plugin(name: str): - """Download and install a plugin from the catalog, then hot-reload it. - - Only the plugin's name comes from the request. The download URL and - checksum are read from the configured catalogs, so a request cannot point - Steward at a file of its own choosing. - """ - from steward.core.plugin_index import fetch_catalog - from steward.core.plugin_manager import download_and_install_plugin, hot_reload_plugin - - async with current_app.db_sessionmaker() as db: - settings = await get_all_settings(db) - entry = next((p for p in await fetch_catalog(repo_urls(settings)) if p.name == name), None) - if entry is None: - return await render_template( - "settings/_install_result.html", - name=name, success=False, - message="No configured plugin repository lists this plugin.", - ) - - ok, msg = await download_and_install_plugin( - current_app._get_current_object(), entry.name, entry.download_url, entry.checksum_sha256 - ) - await log_audit( - current_app, session.get("user_id"), session.get("username", ""), - "plugin.install" if ok else "plugin.install_failed", entity_type="plugin", - entity_id=name, detail={"version": entry.version, "url": entry.download_url, - "message": msg}) - if not ok: - return await render_template( - "settings/_install_result.html", - name=name, success=False, message=msg, - ) - - # Attempt hot-reload - ok2, msg2 = hot_reload_plugin(current_app._get_current_object(), name) - if ok2: - return await render_template( - "settings/_install_result.html", - name=name, success=True, - message=f"Installed and activated. {msg2}", - ) - else: - return await render_template( - "settings/_install_result.html", - name=name, success=True, - message=f"Installed. {msg2}", - restart_required=True, - ) - - -# ── Plugin hot-reload ────────────────────────────────────────────────────────── - -@settings_bp.post("/plugins/reload/") -@require_role(UserRole.admin) -async def reload_plugin(name: str): - """Hot-reload a plugin that was installed but not yet active.""" - from steward.core.plugin_manager import hot_reload_plugin - ok, msg = hot_reload_plugin(current_app._get_current_object(), name) - return await render_template( - "settings/_install_result.html", - name=name, success=ok, message=msg, - restart_required=(not ok and "restart" in msg.lower()), - ) - - # ── App restart ─────────────────────────────────────────────────────────────── @settings_bp.post("/plugins/restart/") diff --git a/steward/templates/settings/_catalog_partial.html b/steward/templates/settings/_catalog_partial.html deleted file mode 100644 index 0f7b69d..0000000 --- a/steward/templates/settings/_catalog_partial.html +++ /dev/null @@ -1,114 +0,0 @@ -{# settings/_catalog_partial.html — HTMX partial for plugin catalog #} -
- - {% if error %} -
- {{ error }} -
- {% endif %} - - {% if catalog %} -
- {% for plugin in catalog %} - {% set is_installed = plugin.name in installed_names %} - {% set is_loaded = plugin.name in loaded_names %} - {% set installed_ver = installed_versions.get(plugin.name, "") %} - {# update_available: catalog version is strictly newer than what's on disk #} - {% set update_available = is_installed and installed_ver and installed_ver != plugin.version %} - -
-
-
-
- {{ plugin.name }} - v{{ plugin.version }} - - {% if update_available %} - - Update available — installed v{{ installed_ver }} - - {% elif is_loaded %} - Active - {% elif is_installed %} - Installed - {% endif %} - - {% for tag in plugin.tags %} - {{ tag }} - {% endfor %} -
- {% if plugin.description %} -
{{ plugin.description }}
- {% endif %} -
- {% if plugin.author %}by {{ plugin.author }}{% endif %} - {% if plugin.license %}{{ plugin.license }}{% endif %} - {% if plugin.min_app_version %}requires v{{ plugin.min_app_version }}+{% endif %} -
-
- -
- {% if plugin.homepage %} - Docs - {% elif plugin.repository_url %} - Source - {% endif %} - - {% if (update_available or not is_installed) and not plugin.checksum_sha256 %} - {# Steward installs only what it can verify against the catalog's checksum #} - - Can't {{ "update" if update_available else "install" }}: the catalog lists no checksum - - - {% elif update_available %} - {# Installed but outdated — offer update (download + restart) #} -
{{ csrf_field() }} - -
- - {% elif is_loaded %} - {# Active and up to date — no action needed #} - - {% elif is_installed %} - {# Installed but not active — offer hot-reload #} -
{{ csrf_field() }} - -
- - {% else %} - {# Not installed — offer install #} -
{{ csrf_field() }} - -
- {% endif %} -
-
- - {# Inline result placeholder — replaced by _install_result.html after action #} -
-
- {% endfor %} -
- {% elif not error %} -
No plugins listed in the catalog.
- {% endif %} - -
diff --git a/steward/templates/settings/_install_result.html b/steward/templates/settings/_install_result.html deleted file mode 100644 index ba8a9fe..0000000 --- a/steward/templates/settings/_install_result.html +++ /dev/null @@ -1,20 +0,0 @@ -{# settings/_install_result.html — inline result after install/reload action #} -
- {{ message }} - {% if restart_required %} -  ·  -
{{ csrf_field() }} - -
- {% endif %} -
diff --git a/steward/templates/settings/_plugin_repos.html b/steward/templates/settings/_plugin_repos.html deleted file mode 100644 index 36733cd..0000000 --- a/steward/templates/settings/_plugin_repos.html +++ /dev/null @@ -1,99 +0,0 @@ -{# settings/_plugin_repos.html — HTMX partial for plugin repository management #} -
- - {% if repos %} -
- {% for repo in repos %} - {% set idx = loop.index0 %} - -
- - {# ── View row ────────────────────────────────────────────────────────── #} -
-
-
{{ repo.name or repo.url }}
-
{{ repo.url }}
-
-
- - -
-
- - {# ── Inline edit form ──────────────────────────────────────────────── #} - - -
- {% endfor %} -
- {% else %} -
- No repositories charted. Add one below. -
- {% endif %} - - {# ── Add repository ────────────────────────────────────────────────────────── #} -
- - Add Repository - New ▾ - -
-
-
- - -
-
- - -
-
- -
-
- -
- - diff --git a/steward/templates/settings/plugins.html b/steward/templates/settings/plugins.html index 1924d00..dce0d4d 100644 --- a/steward/templates/settings/plugins.html +++ b/steward/templates/settings/plugins.html @@ -76,7 +76,8 @@
Integrations

External systems Steward pulls data from (e.g. Traefik, UniFi). Enable and configure each one - to surface its own dashboard. Off until you set them up. + to surface its own dashboard. Off until you set them up. A plugin of your own goes in + /data/plugins; restart and it appears here.

{% if integrations %}
@@ -84,7 +85,7 @@
{% else %}
- No integrations installed. Browse the catalog below. + No integrations installed.
{% endif %} @@ -97,8 +98,8 @@

Plugins Steward has settings for, or tried to load, but cannot find on disk — including anything counted by the "failed to load" banner. This usually means the plugin was - removed, but it also happens when an external plugin directory isn't mounted or an - install failed part-way, so nothing is deleted automatically. Removing an entry + removed, but it also happens when an external plugin directory isn't mounted, so + nothing is deleted automatically. Removing an entry discards that plugin's saved configuration, including any credentials.

@@ -145,40 +146,4 @@
{% endif %} -{# ── Plugin Repositories ───────────────────────────────────────────────────── #} -
-
Plugin Repositories
-

- Repositories are remote index.yaml files that Steward checks for available - and updated plugins. Add repos from other developers to expand the plugin catalog. -

- {% include "settings/_plugin_repos.html" %} -
- -{# ── Plugin Catalog ────────────────────────────────────────────────────────── #} -
-
-
Plugin Catalog
- -
- - {% if repos %} -
-
Loading catalog…
-
- {% else %} -
- Add a plugin repository above to browse available plugins. -
- {% endif %} -
- {% endblock %} diff --git a/tests/core/test_plugin_install.py b/tests/core/test_plugin_install.py deleted file mode 100644 index be05d28..0000000 --- a/tests/core/test_plugin_install.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Plugin installs fetch only what the catalog names, verify it, and unpack it -only inside the plugin's own folder.""" -import hashlib -import io -import zipfile -from types import SimpleNamespace -from unittest.mock import AsyncMock, patch - -import pytest - -from steward.core import plugin_manager as pm - - -def make_zip(files: dict[str, str]) -> bytes: - buf = io.BytesIO() - with zipfile.ZipFile(buf, "w") as zf: - for name, text in files.items(): - zf.writestr(name, text) - return buf.getvalue() - - -def sha(content: bytes) -> str: - return hashlib.sha256(content).hexdigest() - - -@pytest.mark.parametrize("name", ["..", "a/b", "evil-name", "", "x.y", "ümlaut"]) -def test_name_must_be_an_identifier(name): - with pytest.raises(pm.PluginInstallError): - pm.check_plugin_name(name) - - -def test_checksum_is_required_and_compared(): - content = b"zip" - with pytest.raises(pm.PluginInstallError, match="no checksum"): - pm.verify_checksum(content, "") - with pytest.raises(pm.PluginInstallError, match="mismatch"): - pm.verify_checksum(content, "0" * 64) - pm.verify_checksum(content, sha(content).upper()) - - -def test_extract_strips_the_plugin_folder(tmp_path): - content = make_zip({"demo-v1.2/__init__.py": "x = 1", "demo-v1.2/sub/a.txt": "a"}) - pm.extract_plugin(content, "demo", tmp_path / "demo") - assert (tmp_path / "demo" / "__init__.py").read_text() == "x = 1" - assert (tmp_path / "demo" / "sub" / "a.txt").read_text() == "a" - - -@pytest.mark.parametrize("member", ["../escape.py", "demo/../../escape.py", "/abs.py"]) -def test_extract_refuses_members_outside_the_folder(tmp_path, member): - content = make_zip({"__init__.py": "", member: "boom"}) - with pytest.raises(pm.PluginInstallError, match="outside"): - pm.extract_plugin(content, "demo", tmp_path / "plugins" / "demo") - assert not (tmp_path / "escape.py").exists() - assert not (tmp_path / "plugins" / "escape.py").exists() - assert not (tmp_path / "plugins" / "demo").exists() # nothing half-written - - -def test_extract_refuses_an_oversized_unpack(tmp_path, monkeypatch): - monkeypatch.setattr(pm, "MAX_PLUGIN_UNPACKED_BYTES", 10) - with pytest.raises(pm.PluginInstallError, match="Unpacks to more than"): - pm.extract_plugin(make_zip({"__init__.py": "x" * 11}), "demo", tmp_path / "demo") - - -def _app(tmp_path): - return SimpleNamespace(config={"PLUGIN_INSTALL_DIR": str(tmp_path), "PLUGIN_DIRS": [str(tmp_path)], - "DATABASE_URL": ""}) - - -@pytest.mark.asyncio -async def test_update_replaces_the_folder_whole(tmp_path): - old = tmp_path / "demo" - old.mkdir() - (old / "gone.py").write_text("old") - content = make_zip({"demo/__init__.py": "new"}) - with patch.object(pm, "_download", new=AsyncMock(return_value=content)): - ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", sha(content)) - assert ok, msg - assert (old / "__init__.py").read_text() == "new" - assert not (old / "gone.py").exists() - assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"] - - -@pytest.mark.asyncio -async def test_a_refused_download_leaves_the_installed_copy(tmp_path): - old = tmp_path / "demo" - old.mkdir() - (old / "__init__.py").write_text("old") - content = make_zip({"demo/__init__.py": "new"}) - with patch.object(pm, "_download", new=AsyncMock(return_value=content)): - ok, msg = await pm.download_and_install_plugin(_app(tmp_path), "demo", "https://x/demo.zip", "") - assert not ok and "no checksum" in msg - assert (old / "__init__.py").read_text() == "old" - assert sorted(p.name for p in tmp_path.iterdir()) == ["demo"] - - -@pytest.mark.asyncio -async def test_route_takes_url_and_checksum_from_the_catalog(client): - async with client.session_transaction() as sess: - sess.update(user_id="u", user_role="admin", username="admin", _csrf="t") - entry = SimpleNamespace(name="demo", version="1.0", download_url="https://catalog/demo.zip", - checksum_sha256="abc") - install = AsyncMock(return_value=(False, "stopped here")) - with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \ - patch("steward.settings.routes.repo_urls", return_value=["https://catalog/"]), \ - patch("steward.core.plugin_index.fetch_catalog", new=AsyncMock(return_value=[entry])), \ - patch("steward.core.plugin_manager.download_and_install_plugin", new=install), \ - patch("steward.settings.routes.log_audit", new=AsyncMock()): - resp = await client.post("/settings/plugins/install/demo", headers={"X-CSRF-Token": "t"}, form={ - "download_url": "https://attacker/evil.zip", "checksum_sha256": "dead"}) - assert resp.status_code == 200 - assert install.await_args.args[1:] == ("demo", "https://catalog/demo.zip", "abc") - - install.reset_mock() - resp = await client.post("/settings/plugins/install/other", headers={"X-CSRF-Token": "t"}) - assert "No configured plugin repository lists this plugin" in await resp.get_data(as_text=True) - install.assert_not_awaited() - - -# ── The catalog is fetched by a scheduled task, never by a page load ───────── -# Background Jobs ruling (operator, 2026-10-08): a Steward page reads stored -# records; the sources are contacted by scheduled collection, or because the -# operator asked (Refresh, install). - -from steward.core import plugin_index as pi # noqa: E402 - - -@pytest.fixture -def fresh_catalog(): - pi.clear_catalog_cache() - yield - pi.clear_catalog_cache() - - -def _fetcher(fetched, fail=()): - async def fetch(url): - fetched.append(url) - if url in fail: - pi._failed_at[url] = 0.0 - return False - pi._url_cache[url] = ({"plugins": [{"name": f"p-{url[-1]}", "version": "1.0"}]}, 0.0) - return True - return fetch - - -@pytest.mark.asyncio -async def test_the_tick_fetches_what_is_due_and_waits_out_a_failure(fresh_catalog): - fetched = [] - with patch.object(pi, "_fetch_one", new=_fetcher(fetched, fail={"https://r/b"})): - assert await pi.refresh_due(["https://r/a", "https://r/b"], now=10.0) == 1 - assert fetched == ["https://r/a", "https://r/b"] - fetched.clear() - # a fresh copy is left alone; the failure is not retried before RETRY_AFTER - assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER - 1.0) == 0 - assert fetched == [] - assert await pi.refresh_due(["https://r/a", "https://r/b"], now=pi.RETRY_AFTER + 1.0) == 0 - assert fetched == ["https://r/b"] - fetched.clear() - assert await pi.refresh_due(["https://r/a"], now=pi.CATALOG_MAX_AGE + 1.0) == 1 - assert fetched == ["https://r/a"] - - -@pytest.mark.asyncio -async def test_the_catalog_page_reads_the_cache_and_only_refresh_fetches(client, fresh_catalog): - async with client.session_transaction() as sess: - sess.update(user_id="u", user_role="admin", username="admin", _csrf="t") - fetched = [] - with patch("steward.settings.routes.get_all_settings", new=AsyncMock(return_value={})), \ - patch("steward.settings.routes.repo_urls", return_value=["https://r/a"]), \ - patch.object(pi, "_fetch_one", new=_fetcher(fetched)): - page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True) - assert fetched == [] and "Not fetched yet: https://r/a" in page - page = await (await client.post("/settings/plugins/catalog/refresh", - headers={"X-CSRF-Token": "t"})).get_data(as_text=True) - assert fetched == ["https://r/a"] and "p-a" in page and "Not fetched yet" not in page - # the next page load shows the stored copy without fetching again - page = await (await client.get("/settings/plugins/catalog/")).get_data(as_text=True) - assert fetched == ["https://r/a"] and "p-a" in page diff --git a/tests/test_no_plugin_catalog.py b/tests/test_no_plugin_catalog.py new file mode 100644 index 0000000..b045a22 --- /dev/null +++ b/tests/test_no_plugin_catalog.py @@ -0,0 +1,27 @@ +"""Steward never downloads plugins (Plugin System ruling, 2026-10-08, #5374). + +First-party plugins ship in the image; a third-party plugin is a folder the +operator mounts in /data/plugins. So there is no catalog to fetch, no +repository to configure and no install route: each guard below names one of +those and fails if it comes back. +""" +from steward.core.settings import DEFAULTS + + +def test_no_route_fetches_lists_or_installs_plugins(app): + rules = [r.rule for r in app.url_map.iter_rules()] + assert any(r.startswith("/settings/plugins/") for r in rules) # the page itself is still there + for gone in ("/settings/plugins/catalog", "/settings/plugins/install", "/settings/plugins/repos", + "/settings/plugins/reload"): + assert not [r for r in rules if r.startswith(gone)], gone + + +def test_no_default_names_a_catalog(): + assert not [k for k in DEFAULTS if k.startswith("plugins.")] + + +def test_no_scheduled_task_fetches_a_catalog(app): + from steward.app import _register_core_tasks + _register_core_tasks(app) + names = [t.name for t in app._task_registry] + assert names and "plugin_catalog_refresh" not in names diff --git a/tests/test_plugin_dirs.py b/tests/test_plugin_dirs.py index 65d7cee..8527039 100644 --- a/tests/test_plugin_dirs.py +++ b/tests/test_plugin_dirs.py @@ -67,8 +67,6 @@ def test_bootstrap_splits_bundled_and_external(tmp_path, monkeypatch): boot = load_bootstrap(config_path=tmp_path / "absent.yaml") assert boot["plugin_dirs"] == ["plugins", "/srv/external-plugins"] - # Installs target the external (writable) root, never the bundled one. - assert boot["plugin_install_dir"] == "/srv/external-plugins" def test_bootstrap_external_defaults_to_data_plugins(tmp_path, monkeypatch): @@ -81,4 +79,3 @@ def test_bootstrap_external_defaults_to_data_plugins(tmp_path, monkeypatch): boot = load_bootstrap(config_path=tmp_path / "absent.yaml") assert boot["plugin_dirs"] == ["plugins", "/data/plugins"] - assert boot["plugin_install_dir"] == "/data/plugins"