mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
8837a26af0
commit
0aea7e3787
3 files changed
+100
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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}')
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user