Files
shopdb-flask/docker-compose.yml
benjamin.baker 2ff7b7e906 Let Corporate Tools push the Report IP tool without granting script run
Report IP is an icon on every Public Desktop in the company, so a help desk
caller asking for it to be re-run is asking for something they could already
do themselves at the keyboard. Getting there through ShopDB meant holding
endpoints.runscript, which is the permission that lets someone push ANY script
in the library - the agent self-upgrade included. Handing that over to run a
desktop shortcut is the bundling the permission split was created to take
apart, so the work was simply not being done through the tool.

The allowance is on one named script, never on the run_script action. The
script id rides in the caller-controlled request body, so relaxing the action
would grant the whole library and quietly rebuild the endpoints.deploy bundle.
The enqueue route resolves the target to a real Script row first and matches
the STORED name, because matching what the requester typed would let anyone
name their way past the grant.

Granting on a name is only defensible while the name cannot be made to mean
something else. The body is source-controlled at
plugins/endpoints/api/templates/report-ip-shortcut.ps1, seeded from there,
pinned by SHA-256 on delivery, and the managed fields are re-asserted on every
seed - so an operator cannot flip the row to runcontext=system, deactivate it,
or edit the body and leave the name still pushable.

The template lives under the plugin rather than agent/ because the image copies
plugins/ but not agent/: reading it from agent/ works on a developer checkout
and fails in the deployed container.

The script runs in the signed-in user's session. This is not a preference.
Launched as SYSTEM the tool re-enters the interactive session and the launcher
exits 0 immediately, reporting success having observed nothing - the same
session-0 boundary the collector already documents for mapped drives. User
context fails closed when nobody is logged on, which is the honest answer.

The seed runs in the migrate service alongside the other seeders, so an
existing site picks the script up on the upgrade it already performs.
2026-09-18 17:25:20 -04:00

251 lines
12 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 &&
flask seed shipped-scripts
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: