diff --git a/CHANGELOG.md b/CHANGELOG.md index 504f719..c40e4a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,68 @@ ADR-007 and ADR-002. ## [Unreleased] +### Added + +- **Printer assignment has a form.** `PrinterAssignmentPicker` is the ONE picker + for both ends: MachineForm gained it (the machine is where the assignment + belongs and there was no way to set it except the relationships card), and + PCForm now uses the same component instead of its own copy, so the two sides + of an override cannot drift. PCForm also stopped reconciling row at a time + through the generic relationship endpoints, which left a PC half-assigned on + an HTTP failure; it calls + `PUT /api/printers/assignments/for-asset/` instead. +- **A relationship type can declare itself singular** + (`relationshiptypes.issingular`, migration 7d34), and `defaultprinter` does. + Setting a second default REPLACES the first. The unique constraint is + (source, target, type), so two different targets were two valid rows and the + resolver took the OLDEST: a new default silently lost. +- **Observed printer queues.** `POST /api/collector/printers` (the ADR-006 hook, + no new transport and no new credential) records what a bay ACTUALLY has in a + plugin-owned table, kept strictly apart from what it is assigned. Adoption is + explicit via `POST /api/printers/assignments/seed-from-observed`, which routes + through the same reconcile path as the editor and REFUSES a queue matching no + known printer. New client script `Report-PrintersToShopDB.ps1`. +- **Wave-gated driver rollout.** `Install-ShopdbPrinterDrivers.ps1` takes + `-WaveStart`, `-Waves`, `-WaveUnit` and `-IgnoreWave`. GE-Enforce offsets each + PC by SHA256(hostname) % 5 MINUTES, which was sized for a JSON check, not a + 100 MB driver set: ungated, ~300 bays pull ~30 GB inside one five-minute + window on the share the whole floor depends on. Each bay derives its wave from + its own hostname, the gate runs BEFORE the manifest is read (the manifest is + on that share too), and it fails closed on an unparseable date. + +### Fixed + +- **The image did not build.** `npm run build` fires a `prebuild` hook that runs + `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend into + the Vite tree and codegens `routes.gen.js`. The Dockerfile's frontend stage + copied `frontend/` alone and flattened it to the stage root, so that path + resolved to `/scripts` and every build since the staging script landed died on + `Cannot find module`. The stage now keeps the repo-relative layout and copies + `scripts/stage-frontend.mjs` and `plugins/` in beside it. Verified end to end: + 16 plugin frontends staged, `frontend/dist` in the final image. +- **The compose stack did not persist `instance/`.** `db_data` was a volume and + the instance directory was not, so `docker compose build api && up -d api` + recreated the container and discarded `plugins.json` along with every upload: + floor plans, branding, model and application images, employee photos, + warranty proofs, slides, printed-part files and the Dell OAuth token. The + visible symptom was a site coming back with its plugins disabled. Both compose + files now mount an `instance_data` volume (the air-gap `migrate` service too, + since `plugin upgrade-all` writes `plugins.json`), and DEPLOY.md carries the + one-time rescue for a stack that predates it. +- **A drifted print queue is corrected, not just a missing one.** Queues were + matched by NAME alone, so a bay whose printer had moved or whose queue was + built on a replaced driver looked converged and printed to the wrong device. + `Set-ShopdbPrinters.ps1` now repoints a wrong port and swaps a wrong driver + IN PLACE with `Set-Printer`, so the queue keeps its name, sharing, permissions + and whoever holds it as their default. The driver is only swapped when the + wanted one is staged, and there is still no removal path in the script. +- **MachineForm's dropdowns all came up empty.** It read `.data.data` off + `computersApi.listAll()`, which already resolves to the array, so the whole + parallel load threw into the catch and the machine's own values never loaded. +- **The legacy import loader dropped `machines.printerid`**, the classic + system's record of each machine's default printer, so the production import + would have lost every one. + ## [0.11.3] - 2026-08-19 Fixes the last of the buildings-and-levels bugs, and brings the shop-floor diff --git a/Dockerfile b/Dockerfile index d51bcd9..c6567d3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,13 @@ COPY wsgi.py ./ # Built SPA from stage 1. Flask serves it via register_frontend_routes. COPY --from=frontendbuild /build/frontend/dist ./frontend/dist +# Create instance/ IN THE IMAGE, owned by the app user, before the chown below. +# Docker seeds an empty named volume from the image's content at the mountpoint, +# ownership included. Without this the mountpoint is created root-owned 0755 and +# the container, which runs as shopdb, cannot write plugins.json or any upload +# into its own instance directory. +RUN mkdir -p /app/instance + RUN useradd --create-home --shell /bin/bash shopdb \ && chown -R shopdb:shopdb /app USER shopdb diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index d5c426d..a13386e 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -72,6 +72,11 @@ services: flask seed permissions && flask seed settings && flask seed reference-data + # plugin upgrade-all rewrites plugins.json, so migrate needs the same + # instance volume api uses. Without it the enabled-plugin list is written + # into a container that exits immediately afterwards. + volumes: + - instance_data:/app/instance api: image: shopdb-flask:${IMAGE_TAG:-0.7.0} @@ -85,6 +90,11 @@ services: <<: *app-env ports: - "${API_PORT:-5001}:5001" + # See docker-compose.yml for what lives here. Same reasoning: /app/instance + # is written state and does not survive a container recreate on its own. + volumes: + - instance_data:/app/instance volumes: db_data: + instance_data: diff --git a/docker-compose.yml b/docker-compose.yml index 8a7d62d..bd3d15a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,15 @@ services: - "${API_PORT:-5001}:5001" volumes: - ./plugins:/app/plugins: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: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index c23fcc4..4d21505 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -187,7 +187,13 @@ docker compose exec -T db mysqldump -u root -p"${MYSQL_ROOT_PASSWORD}" shopdb_fl 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 +in MySQL. Under compose it is the `instance_data` named volume: + +```bash +docker compose run --rm -v "$PWD:/backup" api tar czf /backup/instance-$(date +%F).tar.gz -C /app/instance . +``` + +See [docs/BACKUP-RESTORE.md](BACKUP-RESTORE.md) for the full backup and restore procedure. ## Step 9: Updates @@ -197,8 +203,53 @@ git pull origin main docker compose build api docker compose up -d api docker compose exec api flask db upgrade +docker compose exec api flask plugin upgrade-all +docker compose exec api flask db current # must match `flask db heads` ``` +`plugin upgrade-all` runs the per-plugin Alembic chains (ADR-008), which +`db upgrade` does NOT touch. The air-gap stack runs both in its one-shot +`migrate` service; a connected stack has to ask. + +`up -d api` REPLACES the container. Everything the site has written lives in the +`instance_data` volume for exactly this reason: the enabled-plugin list +(`plugins.json`), uploaded floor plans and branding, model and application +images, employee photos, warranty proofs, slides, printed-part files, and the +Dell OAuth token. If your stack predates that volume, those files are in the old +container's writable layer and an update discards them. Move them across ONCE, +before the next rebuild: + +```bash +docker compose cp api:/app/instance ./instance-rescued # BEFORE pulling new code +docker compose up -d api # creates the volume +docker compose cp ./instance-rescued/. api:/app/instance +docker compose exec -u root api chown -R shopdb:shopdb /app/instance +docker compose restart api +``` + +The `chown` is not optional. `docker compose cp` writes the files with the +copying user's numeric uid, which is only `shopdb` by coincidence if your host +account happens to be uid 1000. Get it wrong and the site reads its restored +files fine and cannot write new ones. + +Then run the migrations and confirm the plugins came back: + +```bash +docker compose exec api flask db upgrade +docker compose exec api flask plugin upgrade-all +docker compose exec api flask db current # must match `flask db heads` +docker compose exec api flask plugin list # the site's plugins, enabled +``` + +Restore `instance/` BEFORE `plugin upgrade-all`: that command works from +`plugins.json`, so running it against an empty instance directory upgrades +nothing and reports success. + +The symptom of having missed this is a site that comes back with its plugins +disabled and image URLs that 404: the MySQL rows survived, the files did not. +Re-enabling by hand works, but `flask plugin apply-profile ` puts +the same list back in one command and is the thing to keep in version control. + 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