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.
250 lines
11 KiB
YAML
250 lines
11 KiB
YAML
# shopdb-flask single-site docker-compose template.
|
|
#
|
|
# Per ADR-004, each adopting facility runs its own stack. This template
|
|
# brings up MySQL + the API container and exposes the API on port 5001.
|
|
# The Vue frontend is served separately by the API in production builds
|
|
# (see register_frontend_routes in shopdb/__init__.py); for dev, run
|
|
# `npm run dev` in frontend/ on a separate port.
|
|
#
|
|
# Usage:
|
|
# cp .env.example .env
|
|
# # edit .env with site-specific secrets and origins
|
|
# docker compose up -d
|
|
#
|
|
# Refresh after pulling new code:
|
|
# docker compose build api
|
|
# docker compose up -d api
|
|
|
|
services:
|
|
db:
|
|
image: mysql:8.4
|
|
# utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the
|
|
# image default. Keeps every site's schema on the same charset/collation.
|
|
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
|
restart: unless-stopped
|
|
environment:
|
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
|
|
MYSQL_DATABASE: shopdb_flask
|
|
MYSQL_USER: shopdb
|
|
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
|
|
volumes:
|
|
- db_data:/var/lib/mysql
|
|
# Bind MySQL to loopback only. The api container reaches db over the
|
|
# compose network regardless of this mapping; the published port is just
|
|
# for local admin tools (mysqldump, a client on the host). Exposing 3306
|
|
# on all interfaces would put the database on the facility network.
|
|
ports:
|
|
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
|
|
healthcheck:
|
|
# 127.0.0.1, not localhost: localhost means the unix socket, and the
|
|
# entrypoint's init pass answers on the socket while running the server
|
|
# with --skip-networking. A socket ping therefore reports healthy DURING
|
|
# init, and the api/migrate services start against a server that is about
|
|
# to restart. Over TCP the probe stays red until the real server listens.
|
|
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
|
|
# Schema, plugin bootstrap and seed data all happen here, not in the api
|
|
# container. One place, one ordering, and a failure stops the rollout instead
|
|
# of being retried forever: restart "no" means a bad migration exits once and
|
|
# leaves the error as the last thing in the log. The api service below will
|
|
# not start at all until this exits 0, so `up -d api` still migrates first -
|
|
# compose honours service_completed_successfully even when a single service
|
|
# is named on the command line.
|
|
migrate:
|
|
build: .
|
|
restart: "no"
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
environment:
|
|
FLASK_ENV: production
|
|
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
|
# Required even though this service never serves a request: create_app()
|
|
# runs config validation before any CLI command dispatches, and the
|
|
# production config refuses to build without an explicit CORS allowlist.
|
|
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
# Chained with && so the first failure stops the rest. Ordering is
|
|
# load-bearing: plugin tables cannot be created before the plugins are
|
|
# installed, and seedscopes only links permissions that already exist as
|
|
# rows, so the seeds run last.
|
|
#
|
|
# The seeds are all create-if-missing (see shopdb/cli/__init__.py), so
|
|
# running them on every start is a no-op once the site is current. They run
|
|
# here rather than only on first deploy because a patch that adds a
|
|
# permission or a default setting has to reach an existing site on the
|
|
# upgrade it already performs - that is the whole point of this service.
|
|
# No entrypoint override: the image entrypoint now only waits for the
|
|
# database and execs, which is exactly the preamble this service wants.
|
|
command:
|
|
- sh
|
|
- -c
|
|
- >
|
|
flask db upgrade &&
|
|
/app/scripts/bootstrap-plugins.sh &&
|
|
flask plugin upgrade-all &&
|
|
flask seed permissions &&
|
|
flask seed settings &&
|
|
flask seed reference-data
|
|
volumes:
|
|
- ./plugins:/app/plugins:ro
|
|
- ./scripts:/app/scripts:ro
|
|
# plugins.json is written here by bootstrap-plugins.sh, so this service
|
|
# needs the same instance volume the api uses. They never run at the same
|
|
# time, so the shared mount is not contended.
|
|
- instance_data:/app/instance
|
|
|
|
nginx:
|
|
image: nginx:1.27-alpine
|
|
restart: unless-stopped
|
|
depends_on:
|
|
- api
|
|
ports:
|
|
- "${API_PORT:-5001}:443"
|
|
volumes:
|
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
|
- ./certs:/etc/nginx/certs:ro
|
|
healthcheck:
|
|
test: ["CMD", "wget", "--spider", "-q", "--no-check-certificate", "https://localhost/api/dashboard/health"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
retries: 3
|
|
|
|
api:
|
|
build: .
|
|
restart: unless-stopped
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
FLASK_ENV: production
|
|
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
|
|
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
|
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
|
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
ZABBIX_URL: ${ZABBIX_URL:-}
|
|
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
|
|
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
|
|
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
|
|
# This file is the source-build dev stack (wilm/dev-be); stable and edge
|
|
# ship through the pull/airgap compose files, not this one. So default the
|
|
# badge label to DEV here - a raw source build is by definition not a
|
|
# release - while still allowing an override from .env.
|
|
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-DEV}
|
|
# Enable ProxyFix for nginx reverse proxy
|
|
TRUSTED_PROXY_COUNT: 1
|
|
ports:
|
|
# Expose on localhost only for debugging (bypass nginx)
|
|
- "127.0.0.1:5002:5001"
|
|
# Terminate TLS in gunicorn with the mounted self-signed cert so the browser
|
|
# sees a secure context. navigator.clipboard and other secure-context-only
|
|
# web APIs refuse to run over plain http on a non-localhost origin, so the
|
|
# test box (accessed by FQDN) needs real https. A site swaps in its own cert
|
|
# under ./certs or fronts this with its IIS/nginx proxy.
|
|
command:
|
|
- gunicorn
|
|
- --bind
|
|
- 0.0.0.0:5001
|
|
# Async workers, not threads. The endpoint agent long-polls
|
|
# /api/endpoints/commands/next and re-polls immediately on a 204, so every
|
|
# agent holds a connection essentially continuously and fleet size IS the
|
|
# concurrency. Under gthread that meant 4 x 8 = 32 slots total, and a
|
|
# single worker's 8 threads could fill with sleeping agents while the CPU
|
|
# sat idle - measured: 8 of 9 threads in timed sleep, UI p95 24s.
|
|
#
|
|
# A held connection under gevent is a greenlet, not an OS thread, so idle
|
|
# is nearly free. Measured on this stack (devnotes/gevent-sizing-simulation.md):
|
|
# 700 simulated agents plus UI, integration and kiosk traffic served with
|
|
# zero errors at UI median 7.5ms, peak RSS 479MB. CPU, not memory or DB
|
|
# connections, is the binding resource.
|
|
- --worker-class
|
|
- gevent
|
|
# DATABASE CONNECTION ARITHMETIC. Read this before changing the number.
|
|
#
|
|
# workers x (pool_size + max_overflow) + migrate + admin vs MySQL
|
|
# max_connections
|
|
#
|
|
# Each worker process owns its own SQLAlchemy pool, so the ceiling is per
|
|
# worker multiplied by the worker count. With the defaults in
|
|
# shopdb/config.py (DB_POOL_SIZE 10, DB_MAX_OVERFLOW 10):
|
|
#
|
|
# 4 x (10 + 10) = 80
|
|
# + the migrate service (a handful, and it has exited before api
|
|
# starts, so it only overlaps during a manual upgrade)
|
|
# + whatever a human has open (mysqldump, a client on the host)
|
|
# = about 90 against the mysql:8.4 default max_connections of 151.
|
|
#
|
|
# The `command:` on the db service above sets charset and collation only,
|
|
# so that 151 is the server default and nothing else in this repo relates
|
|
# the two numbers. Raise --workers to 8 and the ceiling is 160 before any
|
|
# overhead, which is ABOVE 151: MySQL then refuses connections, and the
|
|
# failure shows up as errors from the database rather than anything in the
|
|
# app. So change one of these and change the others deliberately:
|
|
# more workers means either a smaller pool or --max-connections on db.
|
|
#
|
|
# pool_timeout is 5s, not the 30s default, so exhaustion fails fast and
|
|
# legibly instead of hanging every request for half a minute.
|
|
- --workers
|
|
- "4"
|
|
# Per-worker greenlet ceiling. 4 x 2000 is far above any realistic fleet;
|
|
# it exists so a connection spike queues rather than being refused.
|
|
- --worker-connections
|
|
- "2000"
|
|
# 60, not 30: the timeout must exceed _CLAIM_HOLD_SECONDS in
|
|
# plugins/endpoints/api/routes.py or gunicorn kills a healthy long-poll
|
|
# mid-hold. Cheap here because a stuck connection costs a greenlet.
|
|
- --timeout
|
|
- "60"
|
|
- --access-logfile
|
|
- "-"
|
|
# %(D)s is request duration in microseconds. Without it a slow request is
|
|
# indistinguishable from a fast one in the log, and latency has to be
|
|
# reconstructed from outside the container. Keep in sync with the
|
|
# Dockerfile CMD.
|
|
# %({X-Real-IP}i)s shows the real client IP from nginx, not the nginx container IP
|
|
- --access-logformat
|
|
- '%({X-Real-IP}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"'
|
|
- --error-logfile
|
|
- "-"
|
|
# NO TLS in gunicorn - nginx handles it
|
|
- wsgi:app
|
|
healthcheck:
|
|
# Probe plain HTTP (nginx handles TLS now)
|
|
test: ["CMD-SHELL", "curl --silent http://localhost:5001/api/dashboard/health | grep -q '\"status\":\"ok\"'"]
|
|
interval: 30s
|
|
timeout: 5s
|
|
start_period: 10s
|
|
retries: 3
|
|
volumes:
|
|
- ./plugins:/app/plugins:ro
|
|
# Standalone operator scripts shipped inside generated bundles (the
|
|
# endpoint-agent uninstaller). Read-only, same pattern as plugins: the
|
|
# site-agent packaging route reads uninstall-shopdbagent.ps1 from here at
|
|
# download time, so it must be visible in the container.
|
|
- ./scripts:/app/scripts:ro
|
|
# Core modules for development (auth, settings, etc). Dev stack only.
|
|
- ./shopdb:/app/shopdb:ro
|
|
# Self-signed TLS material for the dev/test https listener. Read-only;
|
|
# generated on the host under ./certs and never baked into the image.
|
|
- ./certs:/app/certs:ro
|
|
# /app/instance is WRITTEN state, not code: plugins.json (which plugins
|
|
# this site has enabled), uploaded floor plans, branding, model and
|
|
# application images, employee photos, warranty proofs, slides,
|
|
# printed-part files, and the Dell OAuth token. Without this volume a
|
|
# `docker compose build api && up -d api` recreates the container and
|
|
# takes all of it with it, so the site comes back with its plugins
|
|
# disabled and MySQL rows pointing at files that no longer exist.
|
|
- instance_data:/app/instance
|
|
|
|
volumes:
|
|
db_data:
|
|
instance_data:
|