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
|
# Optional: prevents loading on older app versions
|
||||||
min_app_version: "0.1.0"
|
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"
|
repository_url: "https://github.com/yourname/yourrepo"
|
||||||
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
homepage: "https://github.com/yourname/yourrepo/tree/main/myplugin"
|
||||||
tags:
|
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
|
- **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.
|
||||||
```
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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`
|
- [ ] MCP tools (if any) registered via `register_mcp_tools(register)` with a role and `read_only`
|
||||||
- [ ] Templates namespaced under `templates/myplugin/`
|
- [ ] Templates namespaced under `templates/myplugin/`
|
||||||
- [ ] Plugin enabled in app settings
|
- [ ] 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",
|
"secret_key": "test-secret-key",
|
||||||
"db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS,
|
"db_connect_timeout": DB_CONNECT_TIMEOUT_SECONDS,
|
||||||
"plugin_dirs": ["plugins"],
|
"plugin_dirs": ["plugins"],
|
||||||
"plugin_install_dir": "plugins",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app.config.update(
|
app.config.update(
|
||||||
SECRET_KEY=bootstrap["secret_key"],
|
SECRET_KEY=bootstrap["secret_key"],
|
||||||
DATABASE_URL=bootstrap["database_url"],
|
DATABASE_URL=bootstrap["database_url"],
|
||||||
PLUGIN_DIRS=bootstrap["plugin_dirs"],
|
PLUGIN_DIRS=bootstrap["plugin_dirs"],
|
||||||
PLUGIN_INSTALL_DIR=bootstrap["plugin_install_dir"],
|
|
||||||
TESTING=testing,
|
TESTING=testing,
|
||||||
# Lax keeps the session cookie off cross-site POSTs and iframes; the
|
# 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.
|
# 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;
|
# Fire due Ansible schedules (recurring playbook runs). Checks every minute;
|
||||||
# each schedule's own interval gates whether it actually runs.
|
# each schedule's own interval gates whether it actually runs.
|
||||||
async def run_ansible_schedules():
|
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
|
# • bundled — first-party plugins shipped inside the image at repo-root
|
||||||
# `plugins/`; they version atomically with core and are read-only at runtime.
|
# `plugins/`; they version atomically with core and are read-only at runtime.
|
||||||
# • external — operator-mounted dir for third-party plugins, persisted in the
|
# • 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 is scanned first, so on a name collision the first-party plugin wins.
|
||||||
bundled_plugin_dir = raw.get("plugin_dir", "plugins")
|
bundled_plugin_dir = raw.get("plugin_dir", "plugins")
|
||||||
external_plugin_dir = (
|
external_plugin_dir = (
|
||||||
@@ -72,8 +72,6 @@ def load_bootstrap(config_path: Path | str | None = None) -> dict[str, Any]:
|
|||||||
"secret_key": secret_key,
|
"secret_key": secret_key,
|
||||||
"db_connect_timeout": db_connect_timeout,
|
"db_connect_timeout": db_connect_timeout,
|
||||||
"plugin_dirs": plugin_dirs,
|
"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
|
# steward/core/plugin_manager.py
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
import hashlib
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import zipfile
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import TYPE_CHECKING
|
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", "?"))
|
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]:
|
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
|
This works for plugins that were NOT previously loaded. For plugins that
|
||||||
are already registered (blueprint already mounted), a full restart is
|
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.
|
# the agent's ~30s cadence, then roll up to hourly averages kept much longer.
|
||||||
"metrics.retention.raw_days": 7,
|
"metrics.retention.raw_days": 7,
|
||||||
"metrics.retention.rollup_days": 90,
|
"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
|
# Default-enabled plugins. These are the generic, non-vendor-specific
|
||||||
# bundled plugins (protocols/standards, not a single product) — useful on
|
# bundled plugins (protocols/standards, not a single product) — useful on
|
||||||
# almost any install, so a fresh deployment comes up monitoring rather than
|
# 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.auth.middleware import require_role
|
||||||
from steward.core.audit import audit_request, log_audit
|
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.core.logging_setup import LEVELS as LOG_LEVELS, set_log_level
|
||||||
from steward.models.users import UserRole
|
from steward.models.users import UserRole
|
||||||
from steward.core.settings import (
|
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:
|
def _build_plugin_cfg_from_form(plugin: dict, form) -> dict:
|
||||||
"""Extract config values for one plugin from a form submission."""
|
"""Extract config values for one plugin from a form submission."""
|
||||||
name = plugin["_dir"]
|
name = plugin["_dir"]
|
||||||
@@ -678,11 +666,10 @@ async def plugins():
|
|||||||
for p in discovered:
|
for p in discovered:
|
||||||
p["_kind"] = p.get("kind") or (
|
p["_kind"] = p.get("kind") or (
|
||||||
"capability" if p["_dir"] in CAPABILITY_PLUGINS else "integration")
|
"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
|
# Config left behind by plugins that are no longer installed. Surfaced for an
|
||||||
# explicit operator decision rather than cleaned up automatically: an external
|
# explicit operator decision rather than cleaned up automatically: an external
|
||||||
# plugin can be missing because /data/plugins isn't mounted or an install
|
# plugin can be missing because /data/plugins isn't mounted, and silently
|
||||||
# failed, and silently dropping its row would destroy stored credentials.
|
# dropping its row would destroy stored credentials.
|
||||||
#
|
#
|
||||||
# Load failures are folded in so every plugin counted by the admin banner has
|
# 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
|
# 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"],
|
integrations=[p for p in discovered if p["_kind"] != "capability"],
|
||||||
discovered_plugins=discovered,
|
discovered_plugins=discovered,
|
||||||
orphaned_plugins=orphans,
|
orphaned_plugins=orphans,
|
||||||
repos=repos,
|
|
||||||
settings=settings,
|
settings=settings,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -723,8 +709,6 @@ async def plugin_orphan_remove(name: str):
|
|||||||
|
|
||||||
if removed:
|
if removed:
|
||||||
await _reload_app_config()
|
await _reload_app_config()
|
||||||
from steward.core.plugin_index import clear_catalog_cache
|
|
||||||
clear_catalog_cache()
|
|
||||||
await log_audit(
|
await log_audit(
|
||||||
current_app, session.get("user_id"), session.get("username", ""),
|
current_app, session.get("user_id"), session.get("username", ""),
|
||||||
"plugin.settings_removed", entity_type="plugin", entity_id=name)
|
"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 set_setting(db, f"plugin.{name}", plugin_cfg)
|
||||||
|
|
||||||
await _reload_app_config()
|
await _reload_app_config()
|
||||||
from steward.core.plugin_index import clear_catalog_cache
|
|
||||||
clear_catalog_cache()
|
|
||||||
|
|
||||||
new_enabled = plugin_cfg.get("enabled", False)
|
new_enabled = plugin_cfg.get("enabled", False)
|
||||||
if old_enabled != new_enabled:
|
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))
|
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 ───────────────────────────────────────────────────────────────
|
# ── App restart ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@settings_bp.post("/plugins/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>
|
<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;">
|
<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
|
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>
|
</p>
|
||||||
{% if integrations %}
|
{% if integrations %}
|
||||||
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
|
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="card" style="color:var(--text-muted);font-size:0.9rem;margin-bottom:1.5rem;">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -97,8 +98,8 @@
|
|||||||
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;">
|
<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
|
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
|
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
|
removed, but it also happens when an external plugin directory isn't mounted, so
|
||||||
install failed part-way, so nothing is deleted automatically. Removing an entry
|
nothing is deleted automatically. Removing an entry
|
||||||
discards that plugin's saved configuration, including any credentials.
|
discards that plugin's saved configuration, including any credentials.
|
||||||
</p>
|
</p>
|
||||||
<div style="display:grid;gap:0.6rem;">
|
<div style="display:grid;gap:0.6rem;">
|
||||||
@@ -145,40 +146,4 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
{% 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")
|
boot = load_bootstrap(config_path=tmp_path / "absent.yaml")
|
||||||
|
|
||||||
assert boot["plugin_dirs"] == ["plugins", "/srv/external-plugins"]
|
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):
|
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")
|
boot = load_bootstrap(config_path=tmp_path / "absent.yaml")
|
||||||
|
|
||||||
assert boot["plugin_dirs"] == ["plugins", "/data/plugins"]
|
assert boot["plugin_dirs"] == ["plugins", "/data/plugins"]
|
||||||
assert boot["plugin_install_dir"] == "/data/plugins"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user