Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3 (the warranty sync holding a pooled connection) are being handled separately and are untouched here. - A collector check-in that raced another for the same PC lost everything: the unique index on the application fingerprint raised, nothing caught it, and the whole check-in rolled back, discarding state, events and hardware, answering the agent with form copy meant for a person. The insert now runs in a savepoint, reconciles what the other transaction already wrote, one row per savepoint so a second collision skips a row rather than the batch, and the check-in commits with a warning. This is the shape the delete side of the same function already had. - Enrollment is deliberately unauthenticated, so it now carries a per-address rate limit and a cap on pending requests, and the cap is a setting an admin can see and change. A flood can no longer bury the approval queue or grow the table without bound. - The fleet alert poll no longer formats debug strings on every request. - BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin stops reaching into a core table with raw SQL. - A test walks every asset type descriptor and proves the endpoint it names really refuses a caller without the declared permission. - The plugin settings routes now need a permission; they were telling anyone which modules a site runs and where its components live. - The connection pool is sized explicitly with a short timeout, and the arithmetic against the server's connection ceiling is written next to the worker count instead of being left to defaults nobody chose. - Fleet alerts carry both the correct lowercase keys and the old camelCase pair. Trays older than 1.7.0 match keys case-sensitively and would have gone quiet on every PC not yet upgraded; the old pair goes when they are. - The dev server proxy points at localhost again, with environment variables for the remote-box workflow, and one developer's address is out of the server certificate page.
This commit is contained in:
@@ -130,7 +130,16 @@ from .plugins import plugin_manager
|
||||
# for an enrolled PC (401 SHARED_KEY_REFUSED), and maps a PermissionError from
|
||||
# apply_collector_payload to 403 ASSET_MISMATCH. All optional, so a plugin that
|
||||
# declares none of it is unaffected. Additive, minor bump.
|
||||
__contract_version__ = '0.33.0'
|
||||
# 0.34.0 adds BusinessUnit to the core model surface. It was the one thing an
|
||||
# asset carries that a plugin could not name: the endpoints fleet-alert scope
|
||||
# resolves a business unit per poll, and with no contract name for it the route
|
||||
# had dropped to db.text('SELECT businessunit FROM businessunits ...') against a
|
||||
# core table. That workaround respects the letter of ADR-002 and defeats its
|
||||
# point - a raw query is hidden coupling to the core schema that no contract
|
||||
# bump can catch, and it is the kind of thing that gets copied. Exposing the
|
||||
# model closes the gap instead of leaving the workaround as the worked example.
|
||||
# Additive name on the surface, minor bump.
|
||||
__contract_version__ = '0.34.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -32,6 +32,7 @@ from shopdb.core.models import (
|
||||
Asset,
|
||||
AssetType,
|
||||
AssetStatus,
|
||||
BusinessUnit,
|
||||
Vendor,
|
||||
Model,
|
||||
Communication,
|
||||
@@ -417,6 +418,7 @@ __all__ = [
|
||||
'Asset',
|
||||
'AssetType',
|
||||
'AssetStatus',
|
||||
'BusinessUnit',
|
||||
'Vendor',
|
||||
'Model',
|
||||
'Communication',
|
||||
|
||||
@@ -10,6 +10,18 @@ class ConfigError(Exception):
|
||||
"""Raised when required configuration is missing or unsafe."""
|
||||
|
||||
|
||||
# Connection pool per worker process. Env-driven so a site can size it against
|
||||
# its own MySQL max_connections without editing code; see the arithmetic in
|
||||
# docker-compose.yml next to the worker count.
|
||||
#
|
||||
# Deliberately NOT named SQLALCHEMY_POOL_SIZE and friends: those were
|
||||
# Flask-SQLAlchemy 2.x config keys, removed in 3.x, and reusing the names would
|
||||
# suggest the extension still reads them. It reads SQLALCHEMY_ENGINE_OPTIONS.
|
||||
DB_POOL_SIZE = int(os.environ.get('DB_POOL_SIZE', 10))
|
||||
DB_MAX_OVERFLOW = int(os.environ.get('DB_MAX_OVERFLOW', 10))
|
||||
DB_POOL_TIMEOUT = int(os.environ.get('DB_POOL_TIMEOUT', 5))
|
||||
|
||||
|
||||
def _required_env(varname):
|
||||
"""Read an env var; raise ConfigError if missing or empty."""
|
||||
value = os.environ.get(varname)
|
||||
@@ -32,9 +44,32 @@ class Config:
|
||||
'mysql+pymysql://root:password@localhost:3306/shopdb_flask',
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# Connection pool, sized on purpose rather than left to SQLAlchemy.
|
||||
#
|
||||
# The defaults (pool_size 5, max_overflow 10, pool_timeout 30) were never
|
||||
# chosen: they simply applied, and nothing in the repo related them to the
|
||||
# gunicorn worker count or to the MySQL max_connections ceiling. The
|
||||
# arithmetic that ties the three together is written next to the worker
|
||||
# count in docker-compose.yml.
|
||||
#
|
||||
# Under gevent a pooled connection is held for the duration of whatever the
|
||||
# greenlet is doing, so a request that sits inside a slow external HTTP call
|
||||
# WITH THE SESSION OPEN pins a connection for that whole call. Hundreds of
|
||||
# greenlets can enter such a path at once and drain the pool, at which point
|
||||
# every other request blocks on checkout. Hence the fail-fast timeout: 5
|
||||
# seconds, not 30. A pool that is empty for five seconds is not going to be
|
||||
# fine in thirty, and a fast 500 is diagnosable where a 30-second hang looks
|
||||
# like a dead database.
|
||||
#
|
||||
# See PLUGIN-DEVELOPMENT / the plugin docs: a plugin must not hold a DB
|
||||
# session across external I/O.
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'pool_pre_ping': True,
|
||||
'pool_recycle': 300,
|
||||
'pool_size': DB_POOL_SIZE,
|
||||
'max_overflow': DB_MAX_OVERFLOW,
|
||||
'pool_timeout': DB_POOL_TIMEOUT,
|
||||
# Pin the client connection to utf8mb4 so reads/writes match the
|
||||
# utf8mb4 schema regardless of the server default charset. TestingConfig
|
||||
# (SQLite) replaces this whole dict, so the charset arg never reaches it.
|
||||
@@ -192,6 +227,13 @@ class TestingConfig(Config):
|
||||
# Off by default so login-heavy fixtures do not trip the limiter; tests
|
||||
# that exercise rate limiting flip it on via app.config override.
|
||||
AUTH_RATELIMIT_ENABLED = False
|
||||
# Same reason: the enrollment suites post to /enroll dozens of times, and
|
||||
# the limiter counts in a process-wide cache that outlives a test, so with
|
||||
# it on by default a budget spent in one module surfaces as an unrelated
|
||||
# failure in another - and which modules share a worker depends on the
|
||||
# xdist distribution, so the failure moves around. The tests that are about
|
||||
# the limiter turn it on themselves.
|
||||
ENDPOINTS_ENROLL_RATELIMIT_ENABLED = False
|
||||
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
|
||||
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||
'connect_args': {'check_same_thread': False},
|
||||
|
||||
@@ -43,13 +43,23 @@ def list_enabled_plugins():
|
||||
|
||||
|
||||
@plugins_bp.route('/settings-routes', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
@require_permission('settings.view')
|
||||
def get_plugin_settings_routes():
|
||||
"""Return settings routes contributed by all enabled plugins.
|
||||
|
||||
Plugins can register routes to add tabs/pages to settings hubs like
|
||||
Model Catalog, Asset Config, etc. Frontend router merges these dynamically.
|
||||
|
||||
Gated on settings.view, the same permission list_plugins carries. It was
|
||||
optional-auth and listed as a public read, on the stated reasoning that
|
||||
unauthenticated pages (kiosks, TV displays) register plugin tabs before
|
||||
login. Nothing does: the only caller in the frontend is
|
||||
views/settings/ModelCatalogHub.vue, which sits behind requiresAuth and
|
||||
requiresAdmin, and no kiosk route touches it. Open, it handed an anonymous
|
||||
caller the enabled plugin list, the settings hub layout and the internal Vue
|
||||
component paths - free reconnaissance about which modules a site runs. The
|
||||
login page never calls this, so gating it costs nothing pre-login.
|
||||
|
||||
Returns: { routes: [{hub, path, name, tab, component}, ...] }
|
||||
"""
|
||||
pm = current_app.extensions.get('plugin_manager')
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
"""Base plugin class that all plugins must inherit from."""
|
||||
"""Base plugin class that all plugins must inherit from.
|
||||
|
||||
One rule that is not expressible as a method signature, so it lives here where
|
||||
a plugin author reads it: A PLUGIN MUST NOT HOLD A DB SESSION ACROSS EXTERNAL
|
||||
I/O. Finish with the database, then make the HTTP call / run the subprocess /
|
||||
touch the share, then open a fresh session to write the result. Under gevent a
|
||||
slow external call does not block its worker, but it does pin one of that
|
||||
worker's pooled database connections for its whole duration, and enough
|
||||
concurrent greenlets on such a path drain the pool and stall every other
|
||||
request in the process - the whole site, every plugin, with the CPU idle and
|
||||
MySQL healthy. See CONTRIBUTING.md ("A plugin must not hold a DB session across
|
||||
external I/O") for the shape to write, and docker-compose.yml for how the pool
|
||||
relates to the worker count and to MySQL max_connections.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
Reference in New Issue
Block a user