fix(plugins): hot-load extension blueprints on a live app (#144)

Flask refuses register_blueprint once the app has handled its first
request, so on any running panel — where login and dashboard requests
long precede the first extension install — the hot-load in
_register_plugin_blueprint always raised, the installer only logged a
'will load on restart' warning, and the extension's routes stayed
missing. Its pages rendered (frontend is static) while every API call
failed: GETs were swallowed by the tabs' console.error handlers, and the
first POST fell through to the static-file route, surfacing as the 405
reported for the Malware Scanner custom-path scan.

_register_blueprint_live waives Flask's setup guard for the duration of
the registration call. That is safe in this deployment shape: the panel
runs a single gunicorn worker with threads, so the process installing
the plugin is the process serving its routes, and werkzeug rebuilds its
URL matcher lazily on the next bind while in-flight requests keep the
matcher they already bound.
This commit is contained in:
Juan Denis committed 2026-09-18 01:28:59 -04:00
1 parent 8837a26af0
commit 0aea7e3787
3 files changed
+100 -2

No files matched your search

+7
View File
@@ -38,6 +38,13 @@ release history; historical `agent-v*` tags are not panel releases.
`/opt`, so the Log Files tab could read — or clear — the backend `.env`
and database. The log viewer now shares the file manager's protected-roots
exclusion.
- Make extension hot-load work on a live panel. Flask refuses to register a
blueprint once the app has served its first request, so an extension
installed on a running panel had no API routes until a restart — its pages
loaded, but every call failed, with POSTs surfacing as a 405 (the Malware
Scanner custom-path scan, #144). Blueprint registration now waives Flask's
setup guard for the duration of the call, which is safe in the panel's
single-worker threaded deployment.
## [1.11.4] - 2026-09-12
+31 -2
View File
@@ -1324,6 +1324,33 @@ def _register_extra_blueprints(register, plugin, manifest):
+ (f' (as {name})' if name else ''))
def _register_blueprint_live(app, bp, **options):
"""Register a blueprint on an app that may already be serving requests.
Flask refuses setup calls once the app has handled its first request
(``AssertionError: The setup method 'register_blueprint' can no longer be
called...``), so on any live panel — where login and dashboard requests
long precede the first extension install — hot-load always failed and the
plugin's routes stayed missing until a restart: unmatched POSTs then fell
through to the static route and surfaced as a 405 (#144).
Late registration is safe in this deployment shape: the panel runs a
single gunicorn worker with threads (the process installing the plugin is
the process serving its routes), and werkzeug rebuilds its URL matcher
lazily on the next bind while in-flight requests keep the matcher they
already bound. Temporarily clearing the flag only waives Flask's setup
guard; it does not touch request state.
"""
if not app._got_first_request:
app.register_blueprint(bp, **options)
return
app._got_first_request = False
try:
app.register_blueprint(bp, **options)
finally:
app._got_first_request = True
def _register_plugin_blueprint(plugin):
"""Dynamically register a plugin's Flask blueprint into the running app."""
from flask import current_app
@@ -1344,10 +1371,12 @@ def _register_plugin_blueprint(plugin):
mod = importlib.import_module(full_module)
bp = getattr(mod, bp_name)
_attach_status_guard(bp, plugin.slug)
current_app.register_blueprint(bp, url_prefix=plugin.url_prefix)
_register_blueprint_live(
current_app, bp, url_prefix=plugin.url_prefix)
logger.info(f'Registered blueprint {bp_name} at {plugin.url_prefix}')
_register_extra_blueprints(
current_app.register_blueprint, plugin, plugin.manifest or {})
lambda bp, **opts: _register_blueprint_live(current_app, bp, **opts),
plugin, plugin.manifest or {})
except Exception as e:
raise ValueError(f'Failed to register blueprint: {e}')
+62
View File
@@ -548,3 +548,65 @@ def test_repair_leaves_intact_installs_alone(app, plugin_dirs, monkeypatch):
plugin_service.repair_missing_plugins()
db.session.refresh(plugin)
assert plugin.status == InstalledPlugin.STATUS_ACTIVE
# --------------------------------------------------------------------------- #
# Hot-load on a live app (#144): install must work after the first request
# --------------------------------------------------------------------------- #
def test_blueprint_registers_after_first_request(app):
"""Flask refuses register_blueprint once the app has served a request, and
on a live panel that is always the case by the time anyone installs an
extension — login and dashboard requests come first. The failed hot-load
used to leave the plugin's routes missing until a restart, and an
unmatched POST (e.g. the malware scanner's custom-path scan) fell through
to the static-file route and surfaced as a 405."""
from flask import Blueprint, jsonify
c = app.test_client()
c.get('/') # flip _got_first_request — the state every live panel is in
bp = Blueprint('live_reg_bp', __name__)
@bp.route('/ping', methods=['POST'])
def ping():
return jsonify({'ok': True})
plugin_service._register_blueprint_live(
app, bp, url_prefix='/api/v1/live-reg')
resp = c.post('/api/v1/live-reg/ping')
assert resp.status_code == 200
assert resp.get_json() == {'ok': True}
def test_register_blueprint_live_preserves_first_request_flag(app):
"""The setup guard is waived only for the registration call itself."""
from flask import Blueprint
c = app.test_client()
c.get('/')
assert app._got_first_request is True
bp = Blueprint('live_reg_flag_bp', __name__)
plugin_service._register_blueprint_live(
app, bp, url_prefix='/api/v1/live-flag')
assert app._got_first_request is True
def test_register_blueprint_live_on_fresh_app(app):
"""The no-op branch: an app that has not served a request yet registers
through the plain path."""
from flask import Blueprint, jsonify
bp = Blueprint('live_reg_fresh_bp', __name__)
@bp.route('/ping')
def ping():
return jsonify({'ok': True})
plugin_service._register_blueprint_live(
app, bp, url_prefix='/api/v1/live-fresh')
assert app.test_client().get('/api/v1/live-fresh/ping').status_code == 200