Make main the lean product a site receives

main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
This commit is contained in:
cproudlock
2026-09-19 11:24:40 -04:00
parent cb3975049c
commit 534f179ee6
675 changed files with 115759 additions and 60239 deletions

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.
@@ -130,15 +165,6 @@ class Config:
EMPLOYEE_DB_PASSWORD = os.environ.get('EMPLOYEE_DB_PASSWORD', '')
EMPLOYEE_DB_NAME = os.environ.get('EMPLOYEE_DB_NAME', 'wjf_employees')
# Read-write CMMC USB check-in/out database (separate from the app DB).
# Credentials come from the environment; never hardcode them in source.
# No safe default for the password: unset means empty, the connection
# fails loud rather than silently trying a guessed credential.
CMMC_USB_DB_HOST = os.environ.get('CMMC_USB_DB_HOST', 'localhost')
CMMC_USB_DB_USER = os.environ.get('CMMC_USB_DB_USER', '')
CMMC_USB_DB_PASSWORD = os.environ.get('CMMC_USB_DB_PASSWORD', '')
CMMC_USB_DB_NAME = os.environ.get('CMMC_USB_DB_NAME', 'cmmc_usb')
CACHE_TYPE = 'SimpleCache'
CACHE_DEFAULT_TIMEOUT = 600
@@ -150,6 +176,22 @@ class Config:
AUTH_RATELIMIT_WINDOW_SECONDS = int(
os.environ.get('AUTH_RATELIMIT_WINDOW_SECONDS', 300))
# Swagger UI "try it" page at /api/docs/try, beside the read-only Redoc
# page at /api/docs. One spec, two renderers.
#
# Off unless a site turns it on, and that is the point rather than caution:
# Swagger UI's Execute button fires a REAL request at THIS server from the
# operator's browser, carrying whatever session that browser already holds.
# On a production box that is a one-click way to POST against live fleet
# data from a page anyone can open. Redoc, which cannot send anything, stays
# the default and is always on.
#
# When off the routes are never bound at all, so /api/docs/try is Flask's
# own 404 and the Redoc page does not mention it. See
# shopdb/core/api/docs.py.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'false').lower() == 'true'
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
@@ -163,11 +205,14 @@ class Config:
# three different routes, which makes it a property of the app rather than
# of any handler.
#
# 128 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 64 MB - with room for the multipart envelope.
# 256 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 128 MB - with room for the multipart
# envelope. This must stay STRICTLY ABOVE the agent cap: it bounds the
# whole request (file + boundaries + form fields), so setting the two
# equal would reject a file sitting exactly at the agent limit.
# Over this, Werkzeug answers 413 without reading the body.
MAX_CONTENT_LENGTH = int(
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
os.environ.get('MAX_CONTENT_LENGTH', 256 * 1024 * 1024))
class DevelopmentConfig(Config):
@@ -176,6 +221,12 @@ class DevelopmentConfig(Config):
DEBUG = True
SQLALCHEMY_ECHO = True
# On in development: firing the request is the whole reason a developer
# opens the API docs. Still read from the environment, so a shared dev
# instance can turn it back off without editing this file.
API_DOCS_TRYIT_ENABLED = os.environ.get(
'API_DOCS_TRYIT_ENABLED', 'true').lower() == 'true'
SQLALCHEMY_DATABASE_URI = os.environ.get(
'DATABASE_URL',
'mysql+pymysql://root:changeme@127.0.0.1:3306/shopdb_flask',
@@ -189,6 +240,23 @@ 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
# Off, and stated here rather than inherited, because a test suite that
# silently ran with it on would be testing a configuration no site ships.
# The session-wide app in tests/conftest.py is what
# tests/test_core/test_public_read_surface.py sweeps for ungated GET
# routes, and the try-it page plus its two vendored assets are
# deliberately unauthenticated. Keeping the default here means that sweep
# keeps describing what production actually exposes; the tests that ARE
# about the page build their own app with the flag on
# (tests/test_core/test_api_docs_tryit.py).
API_DOCS_TRYIT_ENABLED = False
SQLALCHEMY_DATABASE_URI = 'sqlite:///:memory:'
SQLALCHEMY_ENGINE_OPTIONS = {
'connect_args': {'check_same_thread': False},