Act on the wj architectural review: races, limits, pool and contract gaps
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

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:
cproudlock
2026-09-16 08:57:04 -04:00
parent 2557d1967d
commit 066a19052b
30 changed files with 1149 additions and 114 deletions

View File

@@ -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

View File

@@ -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',

View File

@@ -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},

View File

@@ -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')

View File

@@ -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