refactor(plugins): no plugin catalog; plugins ship in the image or are mounted (#5374)
First-party plugins have shipped inside the image since a7a281c, and the
catalog's only repository (Steward-plugins) answers 404, so Steward polled a
dead URL every minute for nothing. The operator chose to remove the catalog
entirely (#5214 "Review: readable and fast on the live instance").
Removed:
- core/plugin_index.py and the plugin_catalog_refresh startup task;
- the plugins.index_url default, and migration 0035 deletes the stored
plugins.index_url / plugins.repositories rows;
- the Settings -> Plugins repository editor, catalog, refresh, install and
reload routes and their templates;
- the download/checksum/extract install path in plugin_manager, and the
PLUGIN_INSTALL_DIR config it wrote to;
- plugins/index.yaml, docs/plugins/index.yaml.example and the guide's
"Publishing to the Catalog" section (now "Shipping a Plugin").
Kept: enabling and configuring plugins, hot-reload on enable, restart, orphan
cleanup, and the external root (/data/plugins) for operator-mounted plugins.
tests/test_no_plugin_catalog.py guards the routes, default and task.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
@@ -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():
|
||||
|
||||
+1
-3
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
+2
-200
@@ -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/<int:idx>/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/<int:idx>/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/<name>")
|
||||
@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/<name>")
|
||||
@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/")
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
{# settings/_catalog_partial.html — HTMX partial for plugin catalog #}
|
||||
<div id="plugin-catalog">
|
||||
|
||||
{% if error %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0.75rem;background:var(--bg);border:1px solid var(--border);border-radius:6px;margin-bottom:0.75rem;">
|
||||
{{ error }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if catalog %}
|
||||
<div style="display:grid;gap:0.75rem;">
|
||||
{% 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 %}
|
||||
|
||||
<div class="card" style="margin-bottom:0;padding:1rem 1.25rem;{% if update_available %}border-color:var(--gold);{% endif %}">
|
||||
<div style="display:flex;align-items:flex-start;gap:1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="display:flex;align-items:center;gap:0.6rem;flex-wrap:wrap;">
|
||||
<span style="font-weight:500;color:var(--text);font-size:0.9rem;">{{ plugin.name }}</span>
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);">v{{ plugin.version }}</span>
|
||||
|
||||
{% if update_available %}
|
||||
<span class="badge badge-gold" style="font-size:0.68rem;">
|
||||
Update available — installed v{{ installed_ver }}
|
||||
</span>
|
||||
{% elif is_loaded %}
|
||||
<span class="badge badge-green" style="font-size:0.68rem;">Active</span>
|
||||
{% elif is_installed %}
|
||||
<span class="badge" style="background:var(--bg-elevated);color:var(--text-muted);font-size:0.68rem;">Installed</span>
|
||||
{% endif %}
|
||||
|
||||
{% for tag in plugin.tags %}
|
||||
<span style="font-size:0.68rem;padding:0.1em 0.4em;background:var(--bg-elevated);color:var(--text-muted);border-radius:3px;">{{ tag }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if plugin.description %}
|
||||
<div style="font-size:0.82rem;color:var(--text-muted);margin-top:0.25rem;">{{ plugin.description }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:0.75rem;color:var(--text-dim);margin-top:0.15rem;display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">
|
||||
{% if plugin.author %}<span>by {{ plugin.author }}</span>{% endif %}
|
||||
{% if plugin.license %}<span style="color:var(--text-muted);">{{ plugin.license }}</span>{% endif %}
|
||||
{% if plugin.min_app_version %}<span style="color:var(--text-muted);">requires v{{ plugin.min_app_version }}+</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:0.5rem;flex-shrink:0;align-items:center;">
|
||||
{% if plugin.homepage %}
|
||||
<a href="{{ plugin.homepage }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Docs</a>
|
||||
{% elif plugin.repository_url %}
|
||||
<a href="{{ plugin.repository_url }}" target="_blank" rel="noopener"
|
||||
class="btn btn-ghost btn-sm" style="font-size:0.78rem;">Source</a>
|
||||
{% 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 #}
|
||||
<span style="font-size:0.75rem;color:var(--text-muted);max-width:14rem;text-align:right;"
|
||||
title="Add checksum_sha256 to this plugin's entry in the repository's index.yaml">
|
||||
Can't {{ "update" if update_available else "install" }}: the catalog lists no checksum
|
||||
</span>
|
||||
|
||||
{% elif update_available %}
|
||||
{# Installed but outdated — offer update (download + restart) #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-warn btn-sm" style="font-size:0.78rem;">Update</button>
|
||||
</form>
|
||||
|
||||
{% elif is_loaded %}
|
||||
{# Active and up to date — no action needed #}
|
||||
|
||||
{% elif is_installed %}
|
||||
{# Installed but not active — offer hot-reload #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/reload/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/reload/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Activate</button>
|
||||
</form>
|
||||
|
||||
{% else %}
|
||||
{# Not installed — offer install #}
|
||||
<form method="post"
|
||||
action="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-post="/settings/plugins/install/{{ plugin.name }}"
|
||||
hx-target="#catalog-result-{{ plugin.name }}"
|
||||
hx-swap="outerHTML"
|
||||
style="margin:0;">{{ csrf_field() }}
|
||||
<button type="submit" class="btn btn-sm" style="font-size:0.78rem;">Install</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Inline result placeholder — replaced by _install_result.html after action #}
|
||||
<div id="catalog-result-{{ plugin.name }}"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif not error %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;">No plugins listed in the catalog.</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
@@ -1,20 +0,0 @@
|
||||
{# settings/_install_result.html — inline result after install/reload action #}
|
||||
<div id="catalog-result-{{ name }}"
|
||||
style="margin-top:0.6rem;padding:0.5rem 0.75rem;border-radius:5px;font-size:0.82rem;
|
||||
background:{% if success %}var(--green-dim){% else %}var(--red-dim){% endif %};
|
||||
color:{% if success %}var(--green){% else %}#ff8090{% endif %};">
|
||||
{{ message }}
|
||||
{% if restart_required %}
|
||||
·
|
||||
<form method="post" action="/settings/plugins/restart/"
|
||||
hx-post="/settings/plugins/restart/"
|
||||
hx-target="#restart-banner"
|
||||
hx-swap="outerHTML"
|
||||
style="display:inline;margin:0;">{{ csrf_field() }}
|
||||
<button type="submit"
|
||||
style="background:none;border:none;padding:0;color:var(--yellow);cursor:pointer;font-size:0.82rem;text-decoration:underline;">
|
||||
Restart now
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -1,99 +0,0 @@
|
||||
{# settings/_plugin_repos.html — HTMX partial for plugin repository management #}
|
||||
<div id="plugin-repos">
|
||||
|
||||
{% if repos %}
|
||||
<div style="display:grid;gap:0.6rem;margin-bottom:0.6rem;">
|
||||
{% for repo in repos %}
|
||||
{% set idx = loop.index0 %}
|
||||
|
||||
<div class="card" style="padding:0;overflow:visible;">
|
||||
|
||||
{# ── View row ────────────────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-view-{{ idx }}"
|
||||
style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;">
|
||||
<div style="flex:1;min-width:0;">
|
||||
<div style="font-weight:500;font-size:0.88rem;">{{ repo.name or repo.url }}</div>
|
||||
<div style="font-size:0.77rem;color:var(--text-muted);margin-top:0.1rem;
|
||||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ repo.url }}</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.4rem;flex-shrink:0;">
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
onclick="toggleRepoEdit({{ idx|int }})">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/plugins/repos/{{ idx }}/remove"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Remove repository '{{ repo.name or repo.url }}'?">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ── Inline edit form ──────────────────────────────────────────────── #}
|
||||
<div id="plugin-repo-edit-{{ idx }}"
|
||||
style="display:none;border-top:1px solid var(--border);padding:1rem;">
|
||||
<form hx-post="/settings/plugins/repos/{{ idx }}/save"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Name</label>
|
||||
<input type="text" name="name" value="{{ repo.name }}" placeholder="My Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url" value="{{ repo.url }}"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;margin-top:0.75rem;">
|
||||
<button type="submit" class="btn btn-sm">Save</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm"
|
||||
onclick="toggleRepoEdit({{ idx|int }})">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.88rem;text-align:center;padding:1.5rem;margin-bottom:0.6rem;">
|
||||
No repositories charted. Add one below.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Add repository ────────────────────────────────────────────────────────── #}
|
||||
<details class="card" style="padding:0;overflow:hidden;">
|
||||
<summary style="display:flex;align-items:center;gap:0.75rem;padding:0.85rem 1rem;
|
||||
cursor:pointer;list-style:none;user-select:none;">
|
||||
<span style="flex:1;font-weight:500;font-size:0.875rem;color:var(--text);">Add Repository</span>
|
||||
<span class="btn btn-sm" style="pointer-events:none;">New ▾</span>
|
||||
</summary>
|
||||
<form hx-post="/settings/plugins/repos/add"
|
||||
hx-target="#plugin-repos"
|
||||
hx-swap="outerHTML"
|
||||
style="padding:1rem;padding-top:0;border-top:1px solid var(--border);">
|
||||
<div style="display:grid;grid-template-columns:1fr 2fr;gap:0.6rem;padding-top:1rem;">
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Name</label>
|
||||
<input type="text" name="name" placeholder="My Plugins">
|
||||
</div>
|
||||
<div class="form-group" style="margin:0;">
|
||||
<label style="font-size:0.8rem;">Index URL</label>
|
||||
<input type="url" name="url"
|
||||
placeholder="https://example.com/plugins/index.yaml" required>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm" style="margin-top:0.75rem;width:100%;">
|
||||
Add Repository
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function toggleRepoEdit(idx) {
|
||||
var edit = document.getElementById('plugin-repo-edit-' + idx);
|
||||
edit.style.display = edit.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
</script>
|
||||
@@ -76,7 +76,8 @@
|
||||
<div class="section-title" style="margin-bottom:0.4rem;">Integrations</div>
|
||||
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
|
||||
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
|
||||
<code>/data/plugins</code>; restart and it appears here.
|
||||
</p>
|
||||
{% if integrations %}
|
||||
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
|
||||
@@ -84,7 +85,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
|
||||
No integrations installed. Browse the catalog below.
|
||||
No integrations installed.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -97,8 +98,8 @@
|
||||
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;">
|
||||
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.
|
||||
</p>
|
||||
<div style="display:grid;gap:0.6rem;">
|
||||
@@ -145,40 +146,4 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ── Plugin Repositories ───────────────────────────────────────────────────── #}
|
||||
<div style="margin-bottom:2rem;max-width:720px;">
|
||||
<div class="section-title" style="margin-bottom:0.75rem;">Plugin Repositories</div>
|
||||
<p style="color:var(--text-muted);font-size:0.84rem;margin-bottom:0.75rem;">
|
||||
Repositories are remote <code>index.yaml</code> files that Steward checks for available
|
||||
and updated plugins. Add repos from other developers to expand the plugin catalog.
|
||||
</p>
|
||||
{% include "settings/_plugin_repos.html" %}
|
||||
</div>
|
||||
|
||||
{# ── Plugin Catalog ────────────────────────────────────────────────────────── #}
|
||||
<div style="max-width:720px;">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.75rem;">
|
||||
<div class="section-title">Plugin Catalog</div>
|
||||
<button class="btn btn-ghost btn-sm" style="font-size:0.78rem;"
|
||||
hx-post="/settings/plugins/catalog/refresh"
|
||||
hx-target="#plugin-catalog"
|
||||
hx-swap="outerHTML">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if repos %}
|
||||
<div id="plugin-catalog"
|
||||
hx-get="/settings/plugins/catalog/"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML">
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">Loading catalog…</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div id="plugin-catalog" style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
|
||||
Add a plugin repository above to browse available plugins.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user