Six procedures that could not be followed as written.
Eighty-nine curl examples single-quoted `Authorization: Bearer $TOKEN`, so the
shell never expanded it and the server answered 422 "Not enough segments". Nine
more did the same with X-API-Key. The other 129 examples in the same file
already used double quotes, so this was drift rather than a convention, and the
spec regenerated from it carried the fault onward.
The GE-Enforce report example put a `//` comment inside a JSON body. The server
parses with silent=True, so it saw `{}` and answered "hostname is required"
about a body that plainly has one - the worst kind of error message, one that
sends the reader to the wrong field entirely.
The IIS install ran `flask db upgrade` and a per-plugin install loop but never
`flask plugin upgrade-all`, leaving every plugin's own chain unapplied. That is
precisely the 1054 "Unknown column" a deploy then hits somewhere else, days
later, on the page that uses the new column.
The pilot runbook looped `flask plugin enable` over plugins that were not yet
installed; enable refuses those, so on a fresh database it exited 1 on every
iteration and enabled nothing. ADR-013 had already recorded that defect.
`apply-profile` installs and enables in dependency order, which is what the step
was reaching for.
DEPLOY-WINDOWS-IIS named a profile file that does not exist; the shipped ones do.
And PLUGIN-EXTERNAL-REPO never mentioned PLUGIN_TABLE_OWNERS, while the
migration engine raises for any plugin missing from it - so the guide's own
step 5 fails for any external plugin that owns a table. That the registry lives
in the framework repo is deliberate, so the guide now says so, and says what it
costs: a table-owning external plugin is a two-repository change, and a plugin
that owns no tables avoids it entirely.
260 lines
11 KiB
Markdown
260 lines
11 KiB
Markdown
# ShopDB - Windows + IIS install runbook
|
|
|
|
> **Not the route for a new site.** Sister sites install from the Windows
|
|
> installer - one `.exe`, no manual IIS work: **[INSTALL-WINDOWS.md](INSTALL-WINDOWS.md)**.
|
|
>
|
|
> This document is the **manual** procedure, kept for reference and for
|
|
> hand-built servers that predate the installer. Note that the installer will not
|
|
> adopt a server built this way without `-AdoptExisting`, on purpose.
|
|
|
|
|
|
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.14** (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 8.4 LTS** (standard for new installs) | reachable from the app host. 8.0 reached end of life in April 2026 and no longer ships a standalone server MSI. 5.7+ still works on an existing server; 5.6 needs the flags in step 1. |
|
|
| 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","machines","network","notifications","printers","knowledgebase","slides","warranty") {
|
|
venv\Scripts\flask plugin install $p
|
|
}
|
|
|
|
# EVERY plugin owns its own Alembic chain (ADR-008), and `flask db upgrade`
|
|
# above ran only the core one. Skipping this is how a new column reaches
|
|
# production as a 1054 "Unknown column" error on the page that uses it.
|
|
venv\Scripts\flask plugin upgrade-all
|
|
|
|
# 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
|
|
|
|
Two supported deployment methods:
|
|
|
|
- **Method A - own site (recommended, default):** the app gets its own IIS
|
|
site, port (or hostname), app pool, and venv. Steps 1-5 below.
|
|
- **Method B - subpath under an existing site:** the app runs as an IIS
|
|
**Application** (e.g. `/ops`) under a site you already have (such as the
|
|
classic ASP site or Default Web Site), so it shares that site's binding and
|
|
TLS cert: `https://<host>/ops/`. Do steps 1-4 below, then follow **7b**
|
|
instead of step 5.
|
|
|
|
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
|
|
mkdir APP_ROOT\instance 2>NUL
|
|
icacls APP_ROOT\instance /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).
|
|
|
|
### 7b. Method B: subpath under an existing site
|
|
|
|
The mount path must match in **three places**: the IIS Application alias, the
|
|
`MOUNT_PATH` the backend sees, and the `VITE_BASE_PATH` the frontend was built
|
|
with. `/ops` is the example throughout; any alias works.
|
|
|
|
1. Rebuild the frontend for the subpath (on the dev box, then copy `dist`):
|
|
```bash
|
|
cd frontend && VITE_BASE_PATH=/ops/ npm run build # note the trailing slash
|
|
```
|
|
2. Create the Application under the existing site (instead of `New-Website`):
|
|
```powershell
|
|
New-WebApplication -Site "Default Web Site" -Name ops -PhysicalPath APP_ROOT -ApplicationPool shopdbflask
|
|
```
|
|
3. Tell the backend its mount path: in `APP_ROOT\web.config`, uncomment the
|
|
`MOUNT_PATH` environment variable (value `/ops`), or set `MOUNT_PATH=/ops`
|
|
in `APP_ROOT\.env`. `wsgi.py` then serves everything under the prefix
|
|
(requests outside it get a plain 404 naming the mount).
|
|
4. Recycle the app pool. The app is at `http(s)://<host>/ops/` and the API at
|
|
`/ops/api/...`.
|
|
|
|
The handler mappings in the app's web.config apply only inside the
|
|
Application, so the parent site's own handlers (classic ASP, static files)
|
|
are untouched. `CORS_ORIGINS` in `.env` is origin-only (scheme + host + port,
|
|
no path), so it is the same for both methods.
|
|
|
|
> 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.
|
|
>
|
|
> Two companion requirements, or the app keeps seeing 127.0.0.1:
|
|
> `allowedServerVariables` is locked at server level by default (500.52 when
|
|
> the block activates) - unlock once with
|
|
> `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`.
|
|
> And waitress 2+ strips X-Forwarded-For from untrusted proxies, so the
|
|
> waitress `arguments` line must carry
|
|
> `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for`
|
|
> (the shipped web.config already does).
|
|
|
|
---
|
|
|
|
## 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. |
|
|
| IIS **500.52** after enabling the rewrite block | `allowedServerVariables` locked at server level - `appcmd unlock config -section:system.webServer/rewrite/allowedServerVariables`. |
|
|
| Audit log shows only **127.0.0.1** with the rewrite block active | waitress strips untrusted proxy headers - `--trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for` missing from the waitress `arguments`. |
|
|
| **500** with an empty HttpPlatform log | app-pool identity can't read `APP_ROOT` / run the venv (step 7.3), or `.env` missing/invalid. |
|
|
| "internal error" toggling plugins, or uploads fail | app pool cannot WRITE `APP_ROOT\instance` (plugin registry, logos, photos, files live there) - step 7.3 grants it Modify. |
|
|
| "No time zone found with key America/New_York" | `tzdata` not installed (`pip install tzdata`). |
|
|
| Nav missing Machines/PCs/... | plugins not installed (step 6 `flask plugin install`), or site not recycled. |
|
|
| Method B: blank page / assets 404 under `/ops` | frontend `dist` built without `VITE_BASE_PATH=/ops/` (step 7b.1). |
|
|
| Method B: SPA loads but every API call 404s | `MOUNT_PATH` unset or not matching the Application alias (step 7b.3). |
|
|
| ConfigError on boot | a required `.env` var missing or left at a dev default. |
|