Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.
Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
prefixes, enable toggle. Defaults point at the current
geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
ships as the map default and sites upload their own blueprint.
Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).
Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.
Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
(naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
plugin contract purity) and the USB label page field mapping (both usb
modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.
248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
123
docs/BACKUP-RESTORE.md
Normal file
123
docs/BACKUP-RESTORE.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# Backup and Restore
|
||||
|
||||
Each site owns its own data (single-tenant, ADR-004), so backups are the site's
|
||||
responsibility. A complete backup is two parts:
|
||||
|
||||
1. **The MySQL database** - all asset, user, audit, and settings data.
|
||||
2. **The `instance/` directory** - uploaded floor plans, branding assets,
|
||||
`plugins.json` (the enabled-plugin list), and any tokens or files the app
|
||||
writes to disk. These are NOT in the database, so a DB-only backup loses
|
||||
them. Back up `instance/` alongside every database dump.
|
||||
|
||||
Restoring the database without the matching `instance/` directory leaves the
|
||||
app pointing at floor plans and logos that no longer exist.
|
||||
|
||||
## What to back up
|
||||
|
||||
| Item | Location | Why |
|
||||
|------|----------|-----|
|
||||
| Database | MySQL `shopdb_flask` | All application data. |
|
||||
| `instance/branding/` | repo `instance/` dir | Uploaded logos and favicon. |
|
||||
| `instance/` floor plans | repo `instance/` dir | Uploaded map blueprints. |
|
||||
| `instance/plugins.json` | repo `instance/` dir | Which plugins this site enabled. |
|
||||
| `.env` | repo root (offline, secured) | Secrets needed to bring the stack back up. Store separately from the data backup, in a secrets manager. |
|
||||
|
||||
## Backup
|
||||
|
||||
### Database (Docker)
|
||||
|
||||
```bash
|
||||
docker compose exec -T db mysqldump \
|
||||
-u root -p"${MYSQL_ROOT_PASSWORD}" \
|
||||
--single-transaction --routines --triggers \
|
||||
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
`--single-transaction` gives a consistent dump without locking the tables (InnoDB).
|
||||
|
||||
### Database (external MySQL, no container)
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <user> -p \
|
||||
--single-transaction --routines --triggers \
|
||||
shopdb_flask | gzip > shopdb-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
### instance directory
|
||||
|
||||
```bash
|
||||
tar czf instance-$(date +%F).tar.gz instance/
|
||||
```
|
||||
|
||||
Recommended cadence: nightly database dump to offsite storage, 14-day
|
||||
retention; `instance/` captured on the same schedule (and always right before an
|
||||
upgrade). Verify a restore quarterly.
|
||||
|
||||
## Restore
|
||||
|
||||
Restoring replaces the current database contents. Do it into a known-empty or a
|
||||
throwaway target first if you are unsure.
|
||||
|
||||
### Step 1: Bring up the stack (or a fresh one)
|
||||
|
||||
```bash
|
||||
cp .env.example .env # or restore your saved .env
|
||||
# ensure MYSQL_* and DATABASE_URL match the dump's database name (shopdb_flask)
|
||||
docker compose up -d db
|
||||
```
|
||||
|
||||
Wait for the `db` container to report healthy (`docker compose ps`).
|
||||
|
||||
### Step 2: Load the database dump
|
||||
|
||||
```bash
|
||||
gunzip -c shopdb-2026-07-10.sql.gz | \
|
||||
docker compose exec -T db mysql -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask
|
||||
```
|
||||
|
||||
For an external MySQL:
|
||||
|
||||
```bash
|
||||
gunzip -c shopdb-2026-07-10.sql.gz | mysql -h <host> -u <user> -p shopdb_flask
|
||||
```
|
||||
|
||||
If the target database does not exist yet, create it as utf8mb4 first (matching
|
||||
the schema charset):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
### Step 3: Restore the instance directory
|
||||
|
||||
```bash
|
||||
tar xzf instance-2026-07-10.tar.gz # restores ./instance/
|
||||
```
|
||||
|
||||
The docker-compose api container reads `instance/` from the repo working
|
||||
directory; make sure it is present before starting `api`.
|
||||
|
||||
### Step 4: Bring up the API and reconcile migrations
|
||||
|
||||
```bash
|
||||
docker compose up -d api
|
||||
docker compose exec api flask db upgrade
|
||||
```
|
||||
|
||||
`flask db upgrade` is a safety net: if the dump predates the current code, this
|
||||
applies any newer migrations. If the dump is at the same version it is a no-op.
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
- Log in with a known account.
|
||||
- Confirm the floor map renders (branding and map blueprints resolve from
|
||||
`instance/`).
|
||||
- Spot-check a few asset records and the audit log.
|
||||
- `curl -s -X POST -H "Content-Type: application/json" -d '{}' http://localhost:5001/api/auth/login | jq .`
|
||||
should return a `VALIDATION_ERROR`, not a 500.
|
||||
|
||||
## See also
|
||||
|
||||
- [DEPLOY.md](DEPLOY.md) - first-time deploy
|
||||
- [UPGRADE.md](UPGRADE.md) - upgrade procedure (back up first)
|
||||
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys
|
||||
@@ -11,6 +11,12 @@ Auth: API key header `X-API-Key: <key>`, resolved as `COLLECTOR_API_KEY_COMPUTER
|
||||
then the shared `COLLECTOR_API_KEY` (ADR-006). Idempotent upsert keyed on
|
||||
`hostname`.
|
||||
|
||||
> **Breaking change:** the API key must be sent in the `X-API-Key` header. The
|
||||
> old `?api_key=<key>` querystring fallback has been removed, on every collector
|
||||
> endpoint (`/api/collector/<plugin>`, `/pc`, `/apps`, `/heartbeat`, `/bulk`,
|
||||
> `/status`). Querystring keys leak into access logs and proxy history. Update
|
||||
> any caller still passing `api_key` in the URL to use the header instead.
|
||||
|
||||
## Payload (project naming convention: lowercase concatenated)
|
||||
|
||||
| Field | Meaning | Flask target |
|
||||
|
||||
242
docs/CONFIG.md
Normal file
242
docs/CONFIG.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# Configuration Reference
|
||||
|
||||
shopdb-flask reads configuration from two places, and the split is deliberate:
|
||||
|
||||
- **Environment variables** (`.env` / container env) hold **secrets and
|
||||
deploy-time wiring**: database credentials, signing keys, CORS origins, ports,
|
||||
API keys. These are read once at boot by `shopdb/config.py`. Never put a
|
||||
secret in the Settings table.
|
||||
- **The Settings table** (seeded by `flask seed settings`, edited in the UI
|
||||
under Settings or the setup wizard) holds **site preferences**: branding,
|
||||
ServiceNow links, floor-map images, search toggles, facility identity. These
|
||||
can change at runtime without a restart and are per-instance.
|
||||
|
||||
Rule of thumb: if leaking it would be a security incident, it is an environment
|
||||
variable. If it is a site preference an admin should be able to change in the
|
||||
UI, it is a Setting.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Environment variables
|
||||
|
||||
Defined in `shopdb/config.py`. Copy `.env.example` to `.env` and fill in
|
||||
values. In `production` (`FLASK_ENV=production`), `ProductionConfig.validate()`
|
||||
refuses to boot if `SECRET_KEY`, `JWT_SECRET_KEY`, `DATABASE_URL`, or
|
||||
`CORS_ORIGINS` are missing or set to the dev defaults.
|
||||
|
||||
### Flask core
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `FLASK_APP` | No | `wsgi.py` | Entry point for the `flask` CLI. |
|
||||
| `FLASK_ENV` | Yes | `development` | `production` for live sites (triggers `validate()`). Other values: `development`, `testing`. |
|
||||
| `SECRET_KEY` | Yes (prod) | dev default | Flask session/signing key. Generate: `python -c "import secrets; print(secrets.token_urlsafe(64))"`. |
|
||||
| `JWT_SECRET_KEY` | Yes (prod) | dev default | JWT signing key. Different value from `SECRET_KEY`. |
|
||||
| `JWT_ACCESS_TOKEN_EXPIRES` | No | `3600` | Access-token TTL in seconds. |
|
||||
| `JWT_REFRESH_TOKEN_EXPIRES` | No | `2592000` | Refresh-token TTL in seconds (30 days). |
|
||||
| `CORS_ORIGINS` | Yes (prod) | `http://localhost:5173` | Comma-separated explicit origins. Wildcard `*` is rejected in production. |
|
||||
| `LOG_LEVEL` | No | `INFO` | Logging verbosity. |
|
||||
|
||||
### Database
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `DATABASE_URL` | Yes (prod) | dev localhost URL | `mysql+pymysql://<user>:<pass>@<host>:<port>/<db>?charset=utf8mb4`. Keep `?charset=utf8mb4`. |
|
||||
|
||||
### Authentication rate limiting
|
||||
|
||||
IP-based fixed-window limit on the login endpoint, defense-in-depth atop the
|
||||
per-account lockout. Uses the existing cache extension (per-process, so the
|
||||
limit is approximate across multiple gunicorn workers).
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `AUTH_RATELIMIT_ENABLED` | No | `True` | Set `False` to disable (TestingConfig disables it). |
|
||||
| `AUTH_RATELIMIT_MAX` | No | `30` | Max login attempts per source IP per window before 429. |
|
||||
| `AUTH_RATELIMIT_WINDOW_SECONDS` | No | `300` | Window length in seconds. |
|
||||
|
||||
### Collector ingest (ADR-006)
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `COLLECTOR_API_KEY` | No | (empty) | Shared key for `/api/collector/*`. Endpoint fails closed (denies) when unset. Sent as the `X-API-Key` header. |
|
||||
| `COLLECTOR_API_KEY_<PLUGIN>` | No | (empty) | Per-plugin override, e.g. `COLLECTOR_API_KEY_COMPUTERS`. Checked before the shared key. |
|
||||
|
||||
### Zabbix (printer supply monitoring)
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `ZABBIX_ENABLED` | No | `false` | Enable the Zabbix integration. |
|
||||
| `ZABBIX_URL` | No | (empty) | Zabbix API URL. |
|
||||
| `ZABBIX_TOKEN` | No | (empty) | Zabbix API bearer token. |
|
||||
|
||||
Note: Zabbix can also be configured via the Settings table (`zabbix_enabled`,
|
||||
`zabbix_url`, `zabbix_token`). The environment values are the boot-time wiring;
|
||||
prefer the Settings entries for runtime changes.
|
||||
|
||||
### Employee directory database (optional, read-only)
|
||||
|
||||
Separate HR/employee lookup DB consumed by the notifications plugin and the
|
||||
public kiosks. There is no safe default for the password; an unset password
|
||||
fails loud rather than trying a guessed credential.
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `EMPLOYEE_DB_HOST` | No | `localhost` | HR DB host. |
|
||||
| `EMPLOYEE_DB_USER` | No | (empty) | HR DB user. |
|
||||
| `EMPLOYEE_DB_PASSWORD` | No | (empty) | HR DB password. No safe default. |
|
||||
| `EMPLOYEE_DB_NAME` | No | `wjf_employees` | HR DB name. |
|
||||
|
||||
Only used when `employee_directory_mode` (Setting) is `external`.
|
||||
|
||||
### CMMC USB database (optional, read-write)
|
||||
|
||||
Separate MySQL DB used by the USB plugin for check-in/out, lockers, and the log.
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `CMMC_USB_DB_HOST` | No | `localhost` | USB DB host. |
|
||||
| `CMMC_USB_DB_USER` | No | (empty) | USB DB user. |
|
||||
| `CMMC_USB_DB_PASSWORD` | No | (empty) | USB DB password. No safe default. |
|
||||
| `CMMC_USB_DB_NAME` | No | `cmmc_usb` | USB DB name. |
|
||||
|
||||
Only used when `usb_directory_mode` (Setting) is `external`.
|
||||
|
||||
### docker-compose only
|
||||
|
||||
Read by `docker-compose.yml`, not by the Flask app directly.
|
||||
|
||||
| Variable | Required | Default | Notes |
|
||||
|----------|----------|---------|-------|
|
||||
| `MYSQL_ROOT_PASSWORD` | Yes | (none) | Root password for the bundled MySQL container. |
|
||||
| `MYSQL_PASSWORD` | Yes | (none) | App-user password; must match the `DATABASE_URL` password. |
|
||||
| `MYSQL_PORT` | No | `3306` | Host port for MySQL. Bound to `127.0.0.1` only. |
|
||||
| `API_PORT` | No | `5001` | Host port for the API container. |
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Settings table keys
|
||||
|
||||
Seeded by `flask seed settings` (idempotent; re-running adds anything missing).
|
||||
Edited in the UI under Settings, or captured in the first-run setup wizard.
|
||||
Values are stored as strings and typed by `valuetype`. Secrets in this table
|
||||
(anything whose key contains `password`, `token`, or `secret`) are masked when
|
||||
read back through the API.
|
||||
|
||||
### site
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `setup_complete` | `false` | Set true once the first-run wizard finishes; gates the `/setup` route. |
|
||||
| `employee_directory_mode` | `selfhosted` | `selfhosted` (tables in this app) or `external` (a separate HR database, see `EMPLOYEE_DB_*`). |
|
||||
| `usb_directory_mode` | `selfhosted` | `selfhosted` or `external` (a separate `cmmc_usb` database, see `CMMC_USB_DB_*`). |
|
||||
| `site_base_url` | (empty) | Public base URL (scheme + host) for QR codes and absolute links. Blank = use the browsing origin. |
|
||||
| `facility_name` | (empty) | Facility name in the dashboard header. Blank = frontend falls back to `ShopDB`. |
|
||||
| `pc_access_domain` | `device.geaerospace.net` | Domain appended to a PC hostname for remote-access links. Blank = hostname as-is. |
|
||||
| `employeeid_pattern` | `^\d{9}$` | Regex a search term must match to be treated as an employee id. Invalid regex falls back to the default and never 500s. |
|
||||
| `printer_hostname_template` | `Printer-{ip}.printer.geaerospace.net` | Printer hostname template. `{ip}` is the dash-separated IP address. |
|
||||
|
||||
### branding
|
||||
|
||||
Blank values fall back to the shipped GE default asset so an un-reconfigured
|
||||
install still renders. Upload replacements at Settings > Branding, which saves
|
||||
them under `instance/branding/`.
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `site_logo` | `/ge-aerospace-logo.svg` | Header and login-page logo. |
|
||||
| `qr_logo` | `/ge-monogram.svg` | Logo composited in printer QR labels. Blank = no overlay. |
|
||||
| `badge_logo` | `/ge-aerospace-logo.svg` | Logo on the equipment badge print page. |
|
||||
| `site_favicon` | (empty) | Browser tab favicon. Blank = shipped `/favicon.svg`. |
|
||||
| `brand_primary_color` | (empty) | Primary brand color as a CSS color value. Blank = built-in theme color. |
|
||||
|
||||
### printing
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `qr_target_printer` | (empty) | Custom URL template for printer QR labels. Blank = link to the printer page on this instance. Placeholders: `{printerid}`, `{assetid}`, `{assetnumber}`, `{serialnumber}`, `{ip}`, `{hostname}`. |
|
||||
| `qr_target_usb` | (empty) | Custom URL template for USB label QR codes. Blank = link to the USB device page. Placeholders: `{id}`, `{serialnumber}`, `{alias}`. |
|
||||
| `usb_label_style` | `barcode` | USB mini-label code style: `barcode` (CODE128 of the serial) or `qr` (QR code linking to the USB QR target). |
|
||||
|
||||
### map
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `map_blueprint_light` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (light theme). Re-upload your own in Settings > Map. |
|
||||
| `map_blueprint_dark` | `/static/images/floorplan-placeholder.svg` | Floor-map blueprint (dark theme). |
|
||||
| `map_width` | `3300` | Blueprint native width in pixels. |
|
||||
| `map_height` | `2550` | Blueprint native height in pixels. |
|
||||
|
||||
### integrations
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `servicenow_enabled` | `true` | Enable ServiceNow ticket recognition and links. Disabled = tickets render as plain text. |
|
||||
| `servicenow_search_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. |
|
||||
| `servicenow_ticket_prefixes` | `GEINC,GECHG,GERIT,GESCT` | Comma-separated prefixes recognized as ServiceNow tickets. |
|
||||
| `servicenow_incident_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct incident URL if your instance has one. |
|
||||
| `servicenow_change_url` | geaerospaceqa.service-now.com global-search template | `{ticket}` is substituted. Replace with a direct change URL if your instance has one. |
|
||||
| `zabbix_enabled` | `false` | Enable Zabbix for printer supply monitoring. |
|
||||
| `zabbix_url` | (empty) | Zabbix API URL. |
|
||||
| `zabbix_token` | (empty) | Zabbix API token (masked). |
|
||||
| `warranty_dell_enabled` | `false` | Enable Dell warranty (service-tag) lookups. |
|
||||
| `warranty_dell_clientid` | (empty) | Dell TechDirect API client id. |
|
||||
| `warranty_dell_clientsecret` | (empty) | Dell TechDirect API client secret (masked). |
|
||||
| `warranty_dell_tokenurl` | (empty) | Dell OAuth token URL. Blank = Dell default. |
|
||||
| `warranty_dell_apiurl` | (empty) | Dell warranty API URL. Blank = Dell default. |
|
||||
|
||||
### email
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `smtp_enabled` | `false` | Enable email notifications and alerts. |
|
||||
| `smtp_host` | (empty) | SMTP server hostname. |
|
||||
| `smtp_port` | `587` | SMTP port (587 TLS, 465 SSL, 25 plain). |
|
||||
| `smtp_username` | (empty) | SMTP auth username. |
|
||||
| `smtp_password` | (empty) | SMTP auth password (masked). |
|
||||
| `smtp_use_tls` | `true` | Use TLS for the SMTP connection. |
|
||||
| `smtp_from_address` | (empty) | From address for outgoing email. |
|
||||
| `smtp_from_name` | `ShopDB` | From name for outgoing email. |
|
||||
| `alert_recipients` | (empty) | Default alert recipients (comma-separated). |
|
||||
|
||||
### audit
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `audit_retention_days` | `90` | Days to retain audit logs (0 = keep forever). |
|
||||
|
||||
### auth
|
||||
|
||||
| Key | Default | Notes |
|
||||
|-----|---------|-------|
|
||||
| `saml_enabled` | `false` | Enable SAML SSO. |
|
||||
| `saml_idp_metadata_url` | (empty) | SAML IdP metadata URL. |
|
||||
| `saml_entity_id` | (empty) | SAML SP entity id. |
|
||||
| `saml_acs_url` | (empty) | SAML Assertion Consumer Service URL. |
|
||||
| `saml_allow_local_login` | `true` | Allow local username/password login when SAML is on. |
|
||||
| `saml_auto_create_users` | `true` | Auto-create users on first SAML login. |
|
||||
| `saml_admin_group` | (empty) | SAML group name that grants the admin role. |
|
||||
|
||||
### identifiers (dynamic)
|
||||
|
||||
One boolean key per asset identifier per asset type, keyed
|
||||
`identifier_<name>_<assettype>_enabled` (default `true`). Admins choose which
|
||||
optional identifiers show on which asset types. See ADR-001. The exact set is
|
||||
generated from `IDENTIFIER_LABELS` x `IDENTIFIER_ASSETTYPES` in
|
||||
`shopdb/core/api/settings.py`.
|
||||
|
||||
### search (dynamic)
|
||||
|
||||
One boolean key per search domain, keyed `search_<type>_enabled` (default
|
||||
`true`). Toggles whether a domain appears in global search results. The set is
|
||||
generated from `SEARCH_DOMAINS` in `shopdb/core/api/settings.py`.
|
||||
|
||||
---
|
||||
|
||||
## See also
|
||||
|
||||
- [DEPLOY.md](DEPLOY.md) - per-site deployment runbook
|
||||
- [UPGRADE.md](UPGRADE.md) - upgrading an existing site
|
||||
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore
|
||||
- `shopdb/config.py` - authoritative env-var definitions
|
||||
- `shopdb/core/api/settings.py` (`build_default_settings`) - authoritative Setting defaults
|
||||
@@ -43,6 +43,13 @@ docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The Docker image builds the Vue frontend in a first stage and copies the
|
||||
compiled SPA into the API image, so `docker compose build` produces a
|
||||
self-contained image with the UI already built. No separate Node step is
|
||||
needed for a container deploy. (For a bare-metal/venv install instead, build
|
||||
the frontend by hand: `cd frontend && npm ci && npm run build`, which writes
|
||||
`frontend/dist/` for Flask to serve.)
|
||||
|
||||
The MySQL container initializes its volume on first run. The API container waits for `db` to be healthy via `healthcheck`. Check logs:
|
||||
|
||||
```bash
|
||||
@@ -67,17 +74,30 @@ CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
`DATABASE_URL` must keep `?charset=utf8mb4` so the connection matches. On MySQL older than 5.7 also enable `innodb_large_prefix=ON` + `innodb_file_format=Barracuda`, or the utf8mb4 indexes exceed the 767-byte prefix limit (error 1071). MySQL 5.7+ and 8.0 need no extra config.
|
||||
|
||||
## Step 4: Seed reference data
|
||||
## Step 4: Seed permissions, settings, and reference data
|
||||
|
||||
Run all three seeders. They are idempotent, so re-running them on an existing
|
||||
database is safe (it adds anything missing and leaves existing rows alone).
|
||||
|
||||
```bash
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
docker compose exec api flask seed reference-data
|
||||
```
|
||||
|
||||
Creates: default `Vendor`, `Location`, `BusinessUnit`, `OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the platform contract values (`partof`, `controls`, `connectedto`).
|
||||
- `seed permissions` - creates the RBAC permission rows and the default roles
|
||||
the app checks with `@require_permission`. Run this before anyone logs in or
|
||||
permission checks have nothing to match.
|
||||
- `seed settings` - writes the default Setting rows (branding, ServiceNow
|
||||
integration, floor-map placeholders, search toggles, site identity). A site
|
||||
overrides these later in Settings or the setup wizard.
|
||||
- `seed reference-data` - creates default `Vendor`, `Location`, `BusinessUnit`,
|
||||
`OperatingSystem`, `AssetStatus`, `RelationshipType` rows seeded with the
|
||||
platform contract values (`partof`, `controls`, `connectedto`).
|
||||
|
||||
## Step 5: Pick plugins to enable
|
||||
|
||||
The image bundles all six plugins (computers, equipment, network, notifications, printers, usb). Only enabled plugins are loaded.
|
||||
The image bundles ten plugins (computers, employees, equipment, knowledgebase, network, notifications, printers, slides, usb, warranty). Only enabled plugins are loaded.
|
||||
|
||||
```bash
|
||||
docker compose exec api flask plugin list
|
||||
@@ -88,7 +108,16 @@ docker compose exec api flask plugin install equipment
|
||||
|
||||
To install a sister-site or third-party plugin (per ADR-003), drop its directory into `<repo>/plugins/<name>/` (the docker-compose mounts this read-only into the container) and run `flask plugin install <name>`.
|
||||
|
||||
## Step 6: Create the admin user
|
||||
## Step 6: Create the first admin (setup wizard)
|
||||
|
||||
The primary path is the first-run setup wizard. Once the stack is up and the DB
|
||||
is seeded, browse to the site (through the reverse proxy configured in Step 7,
|
||||
or directly at the API port during bring-up) and go to `/setup`. The wizard
|
||||
creates the first admin account and captures site identity (facility name,
|
||||
optional logo). It runs only while `setup_complete` is false; after it finishes
|
||||
the route redirects to the app.
|
||||
|
||||
Headless alternative (no browser, e.g. automated provisioning):
|
||||
|
||||
```bash
|
||||
docker compose exec api flask seed admin --username admin --email admin@facility.example.com
|
||||
@@ -129,7 +158,10 @@ Per-site MySQL backups are the site's responsibility. Recommended: nightly `mysq
|
||||
docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > backup-$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
Verify a restore quarterly.
|
||||
Verify a restore quarterly. Back up the `instance/` directory alongside the DB;
|
||||
it holds uploaded floor plans, branding, `plugins.json`, and tokens that are not
|
||||
in MySQL. See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and
|
||||
restore procedure.
|
||||
|
||||
## Step 9: Updates
|
||||
|
||||
@@ -140,7 +172,7 @@ docker compose up -d api
|
||||
docker compose exec api flask db upgrade
|
||||
```
|
||||
|
||||
The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it.
|
||||
The framework's `__contract_version__` may have moved. Check `docs/adr/` for any new ADRs since the last update. If an ADR introduces a breaking change, the upgrade may require coordinated work; the ADR's "Consequences" section documents it. See [docs/UPGRADE.md](UPGRADE.md) for the full upgrade procedure, including re-seeding and the v0.5+ floor-plan note.
|
||||
|
||||
## Common issues
|
||||
|
||||
@@ -169,4 +201,7 @@ If this returns a 500 or no JSON, the container is unhealthy. Check `docker comp
|
||||
- [docs/adr/ADR-003-plugin-distribution.md](adr/ADR-003-plugin-distribution.md) - bundled vs external plugins
|
||||
- [docs/adr/ADR-006-collector-contract.md](adr/ADR-006-collector-contract.md) - per-plugin collector endpoints
|
||||
- [docs/PLUGIN-QUICKSTART.md](PLUGIN-QUICKSTART.md) - building a custom plugin for your site
|
||||
- [docs/CONFIG.md](CONFIG.md) - every environment variable and every Setting key
|
||||
- [docs/UPGRADE.md](UPGRADE.md) - upgrade procedure for an existing site
|
||||
- [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) - backup and restore procedure
|
||||
- [shopdb/config.py](../shopdb/config.py) - all the env-vars in one place
|
||||
|
||||
194
docs/INSTALL-WINDOWS-IIS.md
Normal file
194
docs/INSTALL-WINDOWS-IIS.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# ShopDB - Windows + IIS install runbook
|
||||
|
||||
A step-by-step, **tested** install for a new site on Windows Server / Windows 11
|
||||
with IIS in front of the Flask app (HttpPlatformHandler -> waitress), backed by
|
||||
MySQL. This runbook was validated end to end on a win11 + IIS + MySQL 5.6 box.
|
||||
|
||||
`APP_ROOT` below = the deploy folder, e.g. `C:\shopdb-flask` (where `wsgi.py`
|
||||
lives). Run PowerShell as Administrator.
|
||||
|
||||
---
|
||||
|
||||
## 0. Prerequisites
|
||||
|
||||
| Need | Notes |
|
||||
| --- | --- |
|
||||
| **Python 3.12** (64-bit) | `python --version` |
|
||||
| **IIS** with **HttpPlatformHandler** | https://www.iis.net/downloads/microsoft/httpplatformhandler (direct MSI: `download.microsoft.com/download/8/1/3/813AC4E6-9203-4F7A-8DD5-F3D54D10C5CD/httpPlatformHandler_amd64.msi`) |
|
||||
| **MySQL 5.7+/8.0** (or 5.6 with the flags in step 1) | reachable from the app host |
|
||||
| URL Rewrite (optional) | only for the real-client-IP rule; skip it and the app still runs |
|
||||
|
||||
The app itself pulls in `waitress` and `tzdata` from `requirements.txt` (step 4).
|
||||
|
||||
---
|
||||
|
||||
## 1. MySQL: flags (5.6 only) + database + user
|
||||
|
||||
On **MySQL 5.6 only**, add to `my.ini`/`my.cnf` under `[mysqld]` and restart MySQL
|
||||
(5.7+/8.0 need none of this):
|
||||
|
||||
```
|
||||
innodb_file_per_table = 1
|
||||
innodb_file_format = Barracuda
|
||||
innodb_large_prefix = 1
|
||||
```
|
||||
|
||||
Without them, `flask db upgrade` fails with **error 1071** ("key too long") - the
|
||||
migrations use `ROW_FORMAT=DYNAMIC`, which needs the 3072-byte prefix these unlock.
|
||||
|
||||
Then create the database (utf8mb4) and an app user:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE shopdb_flask CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'shopdb'@'%' IDENTIFIED BY 'CHANGE_ME';
|
||||
GRANT ALL PRIVILEGES ON shopdb_flask.* TO 'shopdb'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Deploy the app files
|
||||
|
||||
Copy the release (the repo minus `venv/`, `.git/`, `node_modules/`,
|
||||
`frontend/src/`) to `APP_ROOT`. It must contain `wsgi.py`, `shopdb/`, `plugins/`,
|
||||
`migrations/`, `requirements.txt`, and the pre-built `frontend/dist/`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Virtual env + dependencies
|
||||
|
||||
```powershell
|
||||
cd APP_ROOT
|
||||
python -m venv venv
|
||||
venv\Scripts\python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
This installs Flask, SQLAlchemy, PyMySQL, **waitress** (the WSGI server IIS
|
||||
launches) and **tzdata** (Windows has no IANA tz database; without it the
|
||||
notifications plugin fails with "No time zone found with key America/New_York").
|
||||
|
||||
---
|
||||
|
||||
## 4. Secrets + connection (.env)
|
||||
|
||||
Create `APP_ROOT\.env` (read by `wsgi.py` via `load_dotenv()`). Lock its ACLs to
|
||||
the app-pool identity + admins.
|
||||
|
||||
```
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=<64+ random chars>
|
||||
JWT_SECRET_KEY=<another 64+ random chars>
|
||||
DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@<mysql-host>:3306/shopdb_flask?charset=utf8mb4
|
||||
CORS_ORIGINS=http://<the site's own hostname-or-ip:port>
|
||||
```
|
||||
|
||||
Generate a key: `venv\Scripts\python -c "import secrets;print(secrets.token_urlsafe(64))"`.
|
||||
Production **refuses to boot** if any of `SECRET_KEY`, `JWT_SECRET_KEY`,
|
||||
`DATABASE_URL`, `CORS_ORIGINS` is missing or a dev default.
|
||||
|
||||
---
|
||||
|
||||
## 5. Preflight (catch problems before installing)
|
||||
|
||||
```powershell
|
||||
$env:FLASK_APP="shopdb"
|
||||
venv\Scripts\flask db-utils preflight
|
||||
```
|
||||
|
||||
Checks Python, required env, DB connectivity, and the MySQL 5.6 index flags, and
|
||||
prints exactly what to fix. Fix any **FAIL** before continuing.
|
||||
|
||||
---
|
||||
|
||||
## 6. Schema + data + plugins + admin
|
||||
|
||||
```powershell
|
||||
$env:FLASK_APP="shopdb"
|
||||
|
||||
venv\Scripts\flask db upgrade # creates every table (to head)
|
||||
venv\Scripts\flask seed reference-data # statuses, machine/location/rel types
|
||||
venv\Scripts\flask seed permissions
|
||||
venv\Scripts\flask seed settings
|
||||
|
||||
# enable the plugins this site tracks (registry is empty on a fresh box).
|
||||
# usb + employees install DISABLED by default - enable them later in the wizard
|
||||
# if the site wants those (they create extra tables).
|
||||
foreach ($p in "computers","equipment","network","notifications","printers","knowledgebase","slides","warranty") {
|
||||
venv\Scripts\flask plugin install $p
|
||||
}
|
||||
|
||||
# first admin (password generated + printed once - store it):
|
||||
venv\Scripts\flask seed admin --username admin --email admin@yourfacility.example.com
|
||||
```
|
||||
|
||||
> Prefer no CLI? Skip `seed admin` (and even the seed steps): start the site, and
|
||||
> the login page offers to **create the first admin** on a fresh instance, then
|
||||
> the setup wizard can seed reference data. Either path works.
|
||||
|
||||
---
|
||||
|
||||
## 7. IIS site
|
||||
|
||||
1. Copy `deploy\windows\web.config` to `APP_ROOT\web.config`. If `APP_ROOT` is not
|
||||
`C:\shopdb-flask`, fix the paths inside it. Create `APP_ROOT\logs`.
|
||||
2. Create an app pool with **No Managed Code**:
|
||||
```powershell
|
||||
Import-Module WebAdministration
|
||||
New-WebAppPool -Name shopdbflask
|
||||
Set-ItemProperty IIS:\AppPools\shopdbflask -Name managedRuntimeVersion -Value ""
|
||||
```
|
||||
3. Grant the app-pool identity access:
|
||||
```powershell
|
||||
icacls APP_ROOT /grant "IIS AppPool\shopdbflask:(OI)(CI)RX" /T
|
||||
icacls APP_ROOT\logs /grant "IIS AppPool\shopdbflask:(OI)(CI)M" /T
|
||||
```
|
||||
4. **Unlock the handler sections** (locked server-wide by default; without this
|
||||
IIS returns **HTTP 500.19**):
|
||||
```powershell
|
||||
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/handlers
|
||||
%windir%\system32\inetsrv\appcmd unlock config /section:system.webServer/httpPlatform
|
||||
```
|
||||
5. Create the site (own port; the classic ASP site can keep 8080):
|
||||
```powershell
|
||||
New-Website -Name shopdb-flask -Port 8090 -PhysicalPath APP_ROOT -ApplicationPool shopdbflask
|
||||
New-NetFirewallRule -DisplayName "shopdb-flask 8090" -Direction Inbound -Protocol TCP -LocalPort 8090 -Action Allow
|
||||
Start-Website shopdb-flask
|
||||
```
|
||||
|
||||
IIS launches `waitress-serve --port=%HTTP_PLATFORM_PORT% wsgi:app` per the
|
||||
web.config and reverse-proxies the site port to it. First request takes ~15s
|
||||
(the app boots + connects to MySQL).
|
||||
|
||||
> The `X-Forwarded-For` URL Rewrite rule in web.config is **commented out by
|
||||
> default**. It needs the URL Rewrite module; with it active but the module
|
||||
> absent, IIS returns 500.19. Install URL Rewrite, then uncomment the
|
||||
> `<rewrite>` block, to record real client IPs in audit logs.
|
||||
|
||||
---
|
||||
|
||||
## 8. Smoke test + first run
|
||||
|
||||
```powershell
|
||||
(Invoke-WebRequest http://localhost:8090/ -UseBasicParsing).StatusCode # 200 (SPA)
|
||||
Invoke-WebRequest http://localhost:8090/api/auth/login -Method POST `
|
||||
-Body '{"username":"admin","password":"<the printed password>"}' `
|
||||
-ContentType application/json -UseBasicParsing # 200 + token
|
||||
```
|
||||
|
||||
Browse to `http://<host>:8090`, sign in as the admin, and the **setup wizard**
|
||||
walks through site name, features (per-plugin: create tables here vs connect a DB),
|
||||
floor-map upload, and starter data. Multiple Flask apps can share one IIS box -
|
||||
each gets its own site, app pool, port, and venv.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Cause / fix |
|
||||
| --- | --- |
|
||||
| `flask db upgrade` -> error **1071** | MySQL 5.6 without the step-1 flags (or server not restarted). |
|
||||
| IIS **500.19** | handler sections not unlocked (step 7.4), or the `<rewrite>` block active without URL Rewrite. |
|
||||
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
|
||||
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
|
||||
| Nav missing Equipment/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
|
||||
| ConfigError on boot | a required `.env` var missing or left at a dev default. |
|
||||
@@ -1,6 +1,6 @@
|
||||
# Roadmap
|
||||
|
||||
shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
shopdb-flask is at `__contract_version__ = '0.5.0'` (pre-1.0). This document captures what stands between today and a stable `1.0.0` release. Maintained as scope evolves; supersedes nothing in the ADRs.
|
||||
|
||||
## Phase status
|
||||
|
||||
@@ -12,7 +12,7 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap
|
||||
| 3 - Manifest-first loader, shopdb.api namespace, auto-register blueprints | DONE | `6f085a1` |
|
||||
| 4 - Plugin scaffolding (`flask plugin new`) | DONE | `8eb9362` |
|
||||
| 5 - Alembic baseline, per-site deploy, ADRs to docs/adr | DONE | `d4e3ac9` |
|
||||
| 6 - Polish (this phase) | IN PROGRESS | this commit |
|
||||
| 6 - Multi-site distribution readiness (settings-driven branding/ServiceNow/floor plan, security closeout, docs + Docker frontend build, release engineering) | IN PROGRESS | this phase |
|
||||
|
||||
## What's left before tagging 1.0.0
|
||||
|
||||
@@ -26,6 +26,9 @@ shopdb-flask is at `__contract_version__ = '0.2.0'` (pre-1.0). This document cap
|
||||
|
||||
### Nice-to-have
|
||||
|
||||
- **Bundle the Roboto font locally.** `frontend/src/assets/style.css:2` imports Roboto from Google Fonts (`fonts.googleapis.com`). Air-gapped facilities have no route to that host, so the font silently falls back to a system font. Vendor the woff2 files into `frontend/src/assets/` and `@font-face` them locally so every site renders identically offline.
|
||||
- **Full palette theming.** `brand_primary_color` is settings-driven, but the rest of the CSS palette (surfaces, borders, accents) is still hardcoded in `style.css`. A complete theming pass would expose the palette as CSS variables a site can override, not just the one primary color.
|
||||
- **Frontend plugin contract.** The backend hook system has no Vue-side equivalent yet (routes/views still ship in core; nav is already backend-driven). See the must-have entry above; this is the design ADR that unblocks external plugins shipping their own UI.
|
||||
- `measuringtools` plugin built using the scaffold (validates the scaffold under realistic conditions).
|
||||
- Frontend scaffolding skill (the backend has `flask plugin new`; the frontend stub is currently manual copy-paste).
|
||||
- Marketplace listing site (PLUGINS.md is a one-pager; a proper listing with links to sister-site plugins becomes useful when there are more than three external plugins).
|
||||
|
||||
112
docs/UPGRADE.md
Normal file
112
docs/UPGRADE.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# Upgrading an existing site
|
||||
|
||||
This is the procedure for moving a running shopdb-flask instance to a newer
|
||||
version. For a first-time install use [DEPLOY.md](DEPLOY.md) instead.
|
||||
|
||||
Each site is single-tenant (ADR-004), so an upgrade touches only that site's
|
||||
own stack. Read the ADRs added since your last update (`docs/adr/`) and the
|
||||
`CHANGELOG.md` before starting; a breaking ADR may require coordinated work.
|
||||
|
||||
## Step 0: Back up first
|
||||
|
||||
Never upgrade without a fresh backup you have tested. Take a full database dump
|
||||
and copy the `instance/` directory. See [BACKUP-RESTORE.md](BACKUP-RESTORE.md).
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_flask | gzip > pre-upgrade-$(date +%F).sql.gz
|
||||
cp -a instance/ instance-backup-$(date +%F)/
|
||||
```
|
||||
|
||||
## Step 1: Get the new code / image
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
```
|
||||
|
||||
The application is distributed through the internal GE Aerospace Gitea; pull
|
||||
from there. There is no external image registry.
|
||||
|
||||
## Step 2: Rebuild
|
||||
|
||||
The Docker image builds the Vue frontend in-image, so a container rebuild picks
|
||||
up frontend changes automatically:
|
||||
|
||||
```bash
|
||||
docker compose build api
|
||||
docker compose up -d api
|
||||
```
|
||||
|
||||
Bare-metal / venv install: rebuild the frontend by hand and refresh Python
|
||||
dependencies:
|
||||
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cd frontend && npm ci && npm run build && cd ..
|
||||
```
|
||||
|
||||
## Step 3: Apply migrations
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker compose exec api flask db upgrade
|
||||
# venv:
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
`flask db upgrade` applies any new migrations in the core Alembic chain. It is
|
||||
idempotent; running it when already at head is a no-op.
|
||||
|
||||
## Step 4: Re-seed permissions and settings
|
||||
|
||||
New versions may add RBAC permissions or default Settings keys. Both seeders are
|
||||
idempotent - they add anything missing and leave existing rows untouched, so
|
||||
your site's customized values are preserved.
|
||||
|
||||
```bash
|
||||
# Docker:
|
||||
docker compose exec api flask seed permissions
|
||||
docker compose exec api flask seed settings
|
||||
# venv:
|
||||
flask seed permissions
|
||||
flask seed settings
|
||||
```
|
||||
|
||||
## Step 5: Restart
|
||||
|
||||
```bash
|
||||
docker compose restart api
|
||||
# venv: restart your process manager, e.g. pm2 restart shopdb-flask-api shopdb-flask-ui
|
||||
```
|
||||
|
||||
Confirm the app is healthy (login page renders, `/api/auth/login` returns a
|
||||
`VALIDATION_ERROR` for an empty body rather than a 500).
|
||||
|
||||
## Version-specific notes
|
||||
|
||||
### Upgrading to v0.5.0 or later: bundled West Jefferson floor plan removed
|
||||
|
||||
Versions before 0.5 shipped the West Jefferson facility floor-plan PNGs as the
|
||||
map default (`/static/images/sitemap2025-light.png` and `-dark.png`). v0.5+
|
||||
removes those bundled PNGs and ships a generic placeholder SVG instead.
|
||||
|
||||
If your instance's `map_blueprint_light` / `map_blueprint_dark` Settings still
|
||||
point at `/static/images/sitemap2025-*`, the map will 404 those images after the
|
||||
upgrade. Re-upload your own floor plan in **Settings > Map**. Uploaded floor
|
||||
plans are stored under `instance/` and survive upgrades, so a site that already
|
||||
uploaded its own plan is unaffected. Only instances still using the old bundled
|
||||
default need to act.
|
||||
|
||||
To check what your instance points at:
|
||||
|
||||
```bash
|
||||
docker compose exec api flask shell -c "from shopdb.core.models.setting import Setting; print(Setting.query.filter(Setting.key.like('map_blueprint%')).all())"
|
||||
```
|
||||
|
||||
## See also
|
||||
|
||||
- [BACKUP-RESTORE.md](BACKUP-RESTORE.md) - what to back up and how to restore
|
||||
- [CONFIG.md](CONFIG.md) - environment variables and Setting keys
|
||||
- [DEPLOY.md](DEPLOY.md) - first-time deploy runbook
|
||||
- `CHANGELOG.md` - what changed in each release
|
||||
115
docs/adr/ADR-007-product-versioning-and-releases.md
Normal file
115
docs/adr/ADR-007-product-versioning-and-releases.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# ADR-007: Product versioning and releases
|
||||
|
||||
- **Status:** ACCEPTED
|
||||
- **Date:** 2026-07-10
|
||||
- **Deciders:** cproudlock
|
||||
- **Supersedes:** none
|
||||
|
||||
## Context
|
||||
|
||||
ADR-002 established semantic versioning for the plugin contract via
|
||||
`__contract_version__` in `shopdb/__init__.py`. That number answers one
|
||||
question only: is a given plugin compatible with this platform build. It
|
||||
says nothing about the state of the product as a whole. A sister site
|
||||
standing up its own instance needs a different answer: which release am I
|
||||
running, and what changed since the last one.
|
||||
|
||||
Until now the product had no version, no tags, no changelog, and no CI.
|
||||
ADR-002's pinning model implicitly assumes that a downstream site can
|
||||
pin a known-good build, but there were no tags to pin to. Adopters had no
|
||||
release record to read before upgrading, and no automated gate confirming
|
||||
that a given commit builds and passes tests.
|
||||
|
||||
## Decision
|
||||
|
||||
The product carries its own release version, separate from the plugin
|
||||
contract version.
|
||||
|
||||
1. **Product version** (`shopdb/__init__.py`): a single `__version__`
|
||||
constant. This is the version of the shopdb-flask product as a whole.
|
||||
It follows semantic versioning of the product's user-visible and
|
||||
operator-visible behavior. It is deliberately NOT part of the
|
||||
`shopdb.api` contract surface, so it is never re-exported through
|
||||
`shopdb.api`; exporting it would itself be a contract change under
|
||||
ADR-002.
|
||||
|
||||
2. **Plugin contract version** (`__contract_version__`): unchanged from
|
||||
ADR-002. It moves only when the plugin contract surface changes, per
|
||||
the major/minor/patch rules in ADR-002.
|
||||
|
||||
The two are distinct series with independent bump rules. They happen to
|
||||
coincide at `0.5.0` for this release; that is a coincidence of timing,
|
||||
not a coupling. A product release that changes no contract surface bumps
|
||||
`__version__` while leaving `__contract_version__` fixed, and vice versa.
|
||||
|
||||
3. **Release record** (`CHANGELOG.md`, repo root): the canonical,
|
||||
human-readable record of what changed in each release, in
|
||||
Keep-a-Changelog format. Every release has an entry; work in flight
|
||||
accumulates under `## [Unreleased]`.
|
||||
|
||||
4. **Git tags**: each product release is tagged `vX.Y.Z`, where `X.Y.Z`
|
||||
matches `__version__` at the tagged commit. Tags are what ADR-002's
|
||||
downstream-pinning model pins to.
|
||||
|
||||
5. **Frontend version** (`frontend/package.json`): kept in lock-step with
|
||||
`__version__` so the shipped single-page app reports the same product
|
||||
version as the backend that serves it.
|
||||
|
||||
### Release procedure
|
||||
|
||||
To cut release `X.Y.Z`:
|
||||
|
||||
1. Bump `__version__` in `shopdb/__init__.py` to `X.Y.Z`.
|
||||
2. Bump `version` in `frontend/package.json` to the same `X.Y.Z`.
|
||||
3. Move the accumulated `## [Unreleased]` notes in `CHANGELOG.md` into a
|
||||
new `## [X.Y.Z] - YYYY-MM-DD` section, leaving a fresh empty
|
||||
`## [Unreleased]` above it.
|
||||
4. If the plugin contract surface changed this release, bump
|
||||
`__contract_version__` per ADR-002 (independently of `__version__`).
|
||||
5. Commit, then tag: `git tag -a vX.Y.Z -m "shopdb-flask X.Y.Z"`.
|
||||
6. Push the commit and the tag.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- Adopters have a real release to name in bug reports and a changelog to
|
||||
read before upgrading.
|
||||
- ADR-002's pinning story finally has tags to pin to.
|
||||
- Product and contract can evolve at their own pace without one dragging
|
||||
the other into a misleading bump.
|
||||
|
||||
### Negative / cost
|
||||
|
||||
- Two version numbers to keep straight. The comment in
|
||||
`shopdb/__init__.py` and this ADR exist to keep the distinction clear.
|
||||
- Release discipline: the changelog and both version constants must be
|
||||
updated together, or the tagged build misreports itself.
|
||||
|
||||
### Neutral
|
||||
|
||||
- CI (`.gitea/workflows/ci.yml`) runs the backend tests, the naming/style
|
||||
gate, and the frontend build on push and PR. It is best-effort: Gitea
|
||||
Actions availability on the host is unverified, so the workflow is
|
||||
config-only until a runner is confirmed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
1. **Reuse `__contract_version__` as the product version.** Conflates two
|
||||
independent concerns; a docs-only or UI-only release would either
|
||||
falsely bump the contract or leave the product looking unchanged.
|
||||
Rejected.
|
||||
2. **Calendar versioning for the product** (e.g. `2026.07.0`). Easy to
|
||||
bump but poor at signaling breaking operator-facing changes. Rejected
|
||||
for the same reasons ADR-002 rejected it for the contract.
|
||||
3. **No product version, rely on git SHAs.** Opaque to adopters and
|
||||
unpinnable in any human-meaningful way. Rejected.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-001 (defines the contract surface)
|
||||
- ADR-002 (plugin contract versioning; `__contract_version__` bump rules)
|
||||
- `shopdb/__init__.py` (`__version__`, `__contract_version__`)
|
||||
- `CHANGELOG.md` (release record)
|
||||
- `frontend/package.json` (frontend version, kept in lock-step)
|
||||
- `.gitea/workflows/ci.yml` (CI gate)
|
||||
@@ -19,6 +19,7 @@ Each ADR captures a single architectural decision: the context, the decision its
|
||||
| [004](ADR-004-deployment-topology.md) | Deployment topology (per-site instances) | ACCEPTED |
|
||||
| [005](ADR-005-equipment-vs-measuringtools.md) | Equipment vs measuringtools plugin scope | ACCEPTED |
|
||||
| [006](ADR-006-collector-contract.md) | Plugin collector contract pattern | ACCEPTED |
|
||||
| [007](ADR-007-product-versioning-and-releases.md) | Product versioning and releases | ACCEPTED |
|
||||
|
||||
## Authoring
|
||||
|
||||
|
||||
Reference in New Issue
Block a user