From d2678139991139df729733eaab3c459ab98ed341 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Thu, 20 Aug 2026 07:46:35 -0400 Subject: [PATCH] Printer assignment end to end, and a container that keeps a site's files Assign printers to a MACHINE and let whichever PC controls it install them, with the observed half kept strictly apart from the assigned half: a collector report can never write an assignment row. Adoption is explicit. Adds a singular relationship type so defaultprinter can mean exactly one, client scripts that converge a bay's queues and correct a drifted one in place, and a wave gate so a fleet-wide driver rollout cannot stampede its own share. On the container side: the compose stack never persisted the instance directory, so a rebuild discarded plugins.json and every upload. It is a volume now, the image ships instance/ owned by the app user so the mountpoint is writable, and the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended since 8.0 went end of life. The image also could not build at all: the frontend stage never copied the plugin staging script it runs. Adds a topology migration guide covering IIS to Docker and back. --- CHANGELOG.md | 148 +++ Dockerfile | 29 +- docker-compose.airgap.yml | 19 +- docker-compose.yml | 18 +- frontend/package.json | 2 +- frontend/src/api/index.js | 19 + .../components/PrinterAssignmentPicker.vue | 168 +++ .../7d34_singular_relationship_types.py | 46 + plugins/computers/api/routes.py | 113 +- .../computers/client/Report-AssetToShopDB.ps1 | 328 +++++ plugins/computers/frontend/routes.js | 6 + .../frontend/views/CollectorSettings.vue | 134 ++ plugins/computers/frontend/views/PCForm.vue | 58 +- plugins/computers/plugin.py | 32 + .../machines/frontend/views/MachineForm.vue | 23 +- plugins/printers/api/asset_routes.py | 1151 ++++++++++++++++- .../client/Apply-ShopdbDefaultPrinter.ps1 | 94 ++ plugins/printers/client/DEPLOYING-DRIVERS.md | 144 +++ .../client/Install-ShopdbPrinterDriver.ps1 | 193 +++ .../client/Install-ShopdbPrinterDrivers.ps1 | 202 +++ .../client/Report-PrintersToShopDB.ps1 | 314 +++++ .../printers/client/Set-ShopdbPrinters.ps1 | 379 ++++++ plugins/printers/client/drivers.example.json | 35 + .../migrations/versions/0003_drivername.py | 43 + .../migrations/versions/0004_drivervendor.py | 44 + .../versions/0005_observedqueues.py | 85 ++ plugins/printers/models/__init__.py | 2 + plugins/printers/models/printer_driver.py | 11 + .../printers/models/printer_observation.py | 109 ++ plugins/printers/plugin.py | 328 ++++- plugins/slides/client/EventSaver.cs | 495 +++++++ plugins/slides/client/EventSaver.ini | 19 + scripts/build-offline-bundle.ps1 | 4 +- scripts/site_imports/wjf/run.py | 106 +- shopdb/__init__.py | 9 +- shopdb/cli/__init__.py | 50 +- shopdb/core/api/assets.py | 21 + shopdb/core/models/relationship.py | 5 + shopdb/plugins/alembic_template.py | 2 +- .../test_singular_relationship_types.py | 116 ++ tests/test_plugin_migrations.py | 7 +- .../test_collector_client_script.py | 122 ++ tests/test_plugins/test_printer_assignment.py | 342 +++++ .../test_printer_driver_resolution.py | 120 ++ .../test_plugins/test_printer_observations.py | 836 ++++++++++++ 45 files changed, 6475 insertions(+), 56 deletions(-) create mode 100644 frontend/src/components/PrinterAssignmentPicker.vue create mode 100644 migrations/versions/7d34_singular_relationship_types.py create mode 100644 plugins/computers/client/Report-AssetToShopDB.ps1 create mode 100644 plugins/computers/frontend/views/CollectorSettings.vue create mode 100644 plugins/printers/client/Apply-ShopdbDefaultPrinter.ps1 create mode 100644 plugins/printers/client/DEPLOYING-DRIVERS.md create mode 100644 plugins/printers/client/Install-ShopdbPrinterDriver.ps1 create mode 100644 plugins/printers/client/Install-ShopdbPrinterDrivers.ps1 create mode 100644 plugins/printers/client/Report-PrintersToShopDB.ps1 create mode 100644 plugins/printers/client/Set-ShopdbPrinters.ps1 create mode 100644 plugins/printers/client/drivers.example.json create mode 100644 plugins/printers/migrations/versions/0003_drivername.py create mode 100644 plugins/printers/migrations/versions/0004_drivervendor.py create mode 100644 plugins/printers/migrations/versions/0005_observedqueues.py create mode 100644 plugins/printers/models/printer_observation.py create mode 100644 plugins/slides/client/EventSaver.cs create mode 100644 plugins/slides/client/EventSaver.ini create mode 100644 tests/test_core/test_singular_relationship_types.py create mode 100644 tests/test_plugins/test_collector_client_script.py create mode 100644 tests/test_plugins/test_printer_assignment.py create mode 100644 tests/test_plugins/test_printer_driver_resolution.py create mode 100644 tests/test_plugins/test_printer_observations.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f37735..293d3b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,154 @@ 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. + +### Changed + +- **MySQL 8.4 LTS** in both compose files, and as the default in + `build-offline-bundle.ps1`. 8.0 reached end of life in April 2026, which + `INSTALL-WINDOWS-IIS.md` already said while the stack still pinned it; the two + halves of the product disagreed about which server a site should run. + Verified against a real 8.4.11 server rather than by changing a tag: the core + chain and five plugin chains applied clean, 66 tables at a single + `utf8mb4_unicode_ci` collation, and the image's PyMySQL authenticates against + 8.4's `caching_sha2_password` (which is why `cryptography` is pinned). + Sites upgrading an EXISTING server should note that 8.4 removes + `mysql_native_password`: an account created on 5.6 or 5.7 must be moved to + `caching_sha2_password` before the upgrade or it cannot log in afterwards. + Air-gapped sites need a fresh offline bundle, since the tarball carries the + MySQL image. +- **The database healthcheck probes over TCP** rather than the unix socket. The + entrypoint's init pass answers on the socket while running the server with + `--skip-networking`, so a socket ping reported healthy DURING init and + `depends_on: service_healthy` released `api` and `migrate` against a server + that was about to restart. Found by having it happen: a probe passed at 8s and + the next query failed because the server was mid-restart. + +### 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 +clients into the product: the collector reporter, EventSaver and a printer +assignment feature that lets a bay install its own printers. + +**Anyone on 0.11.0-0.11.2 should take this.** The hover preview reported "this +asset has a position but no level" for every asset, and the map PDF printed +markers from every floor onto one sheet. + +### Fixed + +- **Every hover mini-map said "no level".** When 0.11.0 gave LocationMapTooltip a + levelid, none of its seven call sites were taught to pass one - printer, + machine and PC detail, the toner report, enforcement reports, the warranty chip + and the dashboard cards. Two payloads behind them also emitted coordinates with + no level. +- **Map PDF export printed other floors' markers.** It plotted every filtered + asset onto one sheet, which is worse than the on-screen version was: nobody can + correct a sheet once it is printed and carried onto the floor. +- **The legacy import loader created undrawable markers**, sending mapleft/maptop + with no level at three call sites. That loader is the one still to run against + production. +- The build gate that should have caught all of this asked whether a FILE + mentions `levelid`, not whether each position does - one module emitted `mapx` + six times and `levelid` once and passed. It now checks per occurrence, covers + `scripts/`, and fails any Vue file that binds tooltip coordinates without a + level. + +### Added + +- **Printer assignment.** Printers belong to the MACHINE and reach whichever PC + controls it, so a reimaged or swapped PC comes back with the bay's printers and + nothing had to be saved off the old one. New `usesprinter` relationship type, + propagating through `controls` at read time; `GET /api/printers/for-host/` + for the client; `PUT /api/printers/assignments/for-asset/` to reconcile a + whole set in one call. +- **`printerdrivers.vendorid`**, so one row serves a make. HP and Xerox universal + drivers cover 41 of the reference site's 44 printers; binding a driver to a + single model meant 21 near-duplicate rows. Resolution is model, then vendor, + then the pre-vendorid name convention. +- **Client scripts** in `plugins/printers/client/`: stage a site's driver set + (`Install-ShopdbPrinterDrivers.ps1`, with `-TestOnly` for DSC), converge a + bay's queues (`Set-ShopdbPrinters.ps1`), and apply the per-user default + (`Apply-ShopdbDefaultPrinter.ps1`). +- **The collector reporter is in the repository** at + `plugins/computers/client/`, with no site in it, and the server generates a + copy carrying this site's URL and ranges: Settings > Computers > Asset + reporter, or `GET /api/computers/client-script`. The collector key is + deliberately never stamped into it. +- **EventSaver source** at `plugins/slides/client/`. The site UNC that was + compiled into it is gone; a missing ini now fails visibly rather than pointing + a new site at the reference site's file server. +- Settings: `computers_routableranges` (replaces two hardcoded VLANs) and the + buildings-and-levels admin gains editable width AND height. + +### Changed + +- The map editor accepts a click. Its handler was bound only if the map was + already a picker when it mounted, and the editor opens with nothing selected, + so placing a marker by hand was impossible for the life of the page. +- Global search orders every query before truncating, so the same search cannot + return a different subset twice. + ## [0.11.2] - 2026-08-17 Bug fixes for the buildings-and-levels work in 0.11.0, all found by using it. diff --git a/Dockerfile b/Dockerfile index d6c04a28..c6567d3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,12 +24,22 @@ FROM node:20-slim AS frontendbuild WORKDIR /build # Copy only the manifests first so `npm ci` caches on dependency changes. -COPY frontend/package*.json ./ -RUN npm ci +COPY frontend/package*.json ./frontend/ +RUN cd frontend && npm ci -COPY frontend/ ./ -RUN npm run build -# Output lands in /build/dist (Vite default), copied into the final stage below. +# The frontend keeps its repo-relative layout here, because npm prebuild runs +# `node ../scripts/stage-frontend.mjs`, which stages each plugin's frontend/ into +# the Vite tree and codegens routes.gen.js. That script resolves its repo root as +# its own directory's parent, so it needs scripts/ and plugins/ as SIBLINGS of +# frontend/. Building with frontend/ flattened to the stage root made `../scripts` +# resolve to /scripts and the build failed on a missing module. +COPY scripts/stage-frontend.mjs ./scripts/ +COPY plugins/ ./plugins/ + +COPY frontend/ ./frontend/ +RUN cd frontend && npm run build +# Output lands in /build/frontend/dist (Vite default), copied into the final +# stage below. # ---- Stage 2: Python application image ---- FROM python:3.14-slim AS base @@ -60,7 +70,14 @@ COPY scripts/ ./scripts/ COPY wsgi.py ./ # Built SPA from stage 1. Flask serves it via register_frontend_routes. -COPY --from=frontendbuild /build/dist ./frontend/dist +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 diff --git a/docker-compose.airgap.yml b/docker-compose.airgap.yml index d5c426d7..7354ccb9 100644 --- a/docker-compose.airgap.yml +++ b/docker-compose.airgap.yml @@ -35,7 +35,7 @@ x-app-env: &app-env services: db: - image: mysql:8.0 + image: mysql:8.4 command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci restart: unless-stopped environment: @@ -48,7 +48,12 @@ services: ports: - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + # 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 @@ -72,6 +77,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 +95,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 8a7d62d3..5f591efe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,7 +17,7 @@ services: db: - image: mysql:8.0 + 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 @@ -36,7 +36,12 @@ services: ports: - "127.0.0.1:${MYSQL_PORT:-3306}:3306" healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"] + # 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 @@ -60,6 +65,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/frontend/package.json b/frontend/package.json index 9d8352e0..e3c1956b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "shopdb-frontend", - "version": "0.11.2", + "version": "0.11.3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 3592d725..0e9e54bb 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -306,6 +306,25 @@ export const printersApi = { list(params = {}) { return api.get('/printers', { params }) }, + + // Printer assignment for one asset - a MACHINE normally, since the assignment + // belongs to the bay and reaches whichever PC controls it. Reads and writes + // that asset's OWN rows: the resolved view (own, else inherited) is + // /printers/for-host, which the client uses, not the editor. + assignment: { + get(assetid) { + return api.get(`/printers/assignments/for-asset/${assetid}`) + }, + // The WHOLE set in one call. A row-at-a-time edit is a non-atomic + // reconcile: an HTTP failure part way leaves an asset half-assigned, with + // nothing recording what was meant. + set(assetid, printerassetids, defaultprinterassetid) { + return api.put(`/printers/assignments/for-asset/${assetid}`, { + printerassetids, + defaultprinterassetid: defaultprinterassetid ?? null + }) + } + }, // Every printer, paged past the backend's 100-row cap. Batch label printing // and "pick any record" dropdowns must use this: list() with a large // perpage is clamped to 100 and still returns a success response, so diff --git a/frontend/src/components/PrinterAssignmentPicker.vue b/frontend/src/components/PrinterAssignmentPicker.vue new file mode 100644 index 00000000..306097a3 --- /dev/null +++ b/frontend/src/components/PrinterAssignmentPicker.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/migrations/versions/7d34_singular_relationship_types.py b/migrations/versions/7d34_singular_relationship_types.py new file mode 100644 index 00000000..047d6f51 --- /dev/null +++ b/migrations/versions/7d34_singular_relationship_types.py @@ -0,0 +1,46 @@ +"""Let a relationship type say "at most one of these per asset". + +A PC has one default printer. The unique constraint on assetrelationships is +(source, target, type), which happily accepts two DIFFERENT defaults on one +asset - and the resolver then takes the oldest, so setting a new default through +the generic relationships card left the old one winning, silently. + +Cardinality belongs to the TYPE, not to the printers plugin: the next type that +means "exactly one" (a primary user, a primary location) gets the rule for free, +and core's create path is where every write already passes. + +Revision ID: 7d34_singular_relationship_types +Revises: 7d33_buildings_and_levels +""" +from alembic import op +import sqlalchemy as sa + + +revision = '7d34_singular_relationship_types' +down_revision = '7d33_buildings_and_levels' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column['name'] for column in inspector.get_columns('relationshiptypes')} + if 'issingular' not in columns: + op.add_column('relationshiptypes', + sa.Column('issingular', sa.Boolean(), nullable=False, + server_default=sa.false())) + + # defaultprinter is the type this exists for, and it is already in use, so + # set it here rather than waiting for a re-seed: a site that upgrades and + # does not re-seed would otherwise keep the old silent behaviour. + op.execute("UPDATE relationshiptypes SET issingular = 1 " + "WHERE LOWER(relationshiptype) = 'defaultprinter'") + + +def downgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {column['name'] for column in inspector.get_columns('relationshiptypes')} + if 'issingular' in columns: + op.drop_column('relationshiptypes', 'issingular') diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 79d0a734..fc9f26a4 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -1,13 +1,13 @@ """Computers plugin API endpoints.""" -from flask import Blueprint, request +from flask import Blueprint, request, Response, current_app from flask_jwt_extended import jwt_required from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess -from shopdb.api import require_permission, apply_import_timestamps +from shopdb.api import require_permission, require_role, apply_import_timestamps computers_bp = Blueprint('computers', __name__) @@ -1064,3 +1064,112 @@ def dashboard_sharedmachines(): out.sort(key=lambda r: -r['pccount']) return success_response(out) + + +# ============================================================================= +# Collector client script +# ============================================================================= + +CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1' + + +def _client_script_path(): + """The reporter shipped with this plugin, which is the single source.""" + import os + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'client', CLIENT_SCRIPT_NAME) + + +def _setting_value(key): + row = Setting.query.filter_by(key=key).first() + return ((row.value if row else '') or '').strip() + + +def _generate_client_script(source: str, baseurl: str, ranges: str, + version: str, generatedon: str) -> str: + """Stamp a site's own values into the reporter's parameter defaults. + + ONLY the defaults are substituted, never the body: the file in the repo + stays runnable as-is, so there is no second copy to drift. Everything + stamped here is overridable at runtime - the parameter still wins, then the + registry - because a bay may need to differ from its site. + + The collector key is NOT stamped in. This file lands on every shop-floor PC, + and a token in a file on hundreds of bays cannot be rotated quietly; it is + read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per + ADOPTING-AT-ANOTHER-SITE.md. + """ + apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else '' + header = ( + '# GENERATED by ShopDB {version} on {generatedon}\n' + '# for {baseurl}\n' + '#\n' + '# Re-download after upgrading ShopDB: this copy matches that server\'s\n' + '# collector contract. Edits here are lost on the next download - change\n' + '# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n' + '#\n' + '# The collector key is deliberately NOT in this file. Provision it as\n' + '# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n' + '\n' + ).format(version=version, generatedon=generatedon, + baseurl=baseurl or 'an unconfigured site (set site_base_url)') + + out = source + if apiurl: + old = "[string]$ApiUrl = ''" + assert old in out, 'the reporter no longer declares $ApiUrl as expected' + out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1) + if ranges: + old = "[string]$AllowedRanges = ''" + assert old in out, 'the reporter no longer declares $AllowedRanges as expected' + out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1) + return header + out + + +@computers_bp.route('/client-script', methods=['GET']) +@jwt_required() +@require_role('admin') +def download_client_script(): + """The collector reporter, stamped with THIS site's values. + + Admin-only. It carries no secret, but it does state a site's URL and its + internal ranges, which is configuration rather than something to hand out. + """ + import datetime + import hashlib + import os + + path = _client_script_path() + if not os.path.isfile(path): + return error_response(ErrorCodes.NOT_FOUND, + 'The collector script is not present in this install', + http_code=404) + + with open(path, 'r', encoding='utf-8') as handle: + source = handle.read() + + # A site that has not set its public URL still gets a usable script: the + # browsing origin is the server the admin is talking to right now. + baseurl = _setting_value('site_base_url') or request.url_root + # From config, not an import: a plugin reaching into core is an ADR-002 + # violation and the contract test fails the build for it. + version = current_app.config.get('VERSION') or 'unknown' + generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d') + + try: + body = _generate_client_script( + source, baseurl.strip(), _setting_value('computers_routableranges'), + version, generatedon) + except AssertionError as exc: + return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500) + + digest = hashlib.sha256(body.encode('utf-8')).hexdigest() + return Response( + body, + mimetype='text/plain; charset=utf-8', + headers={ + 'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME), + # Published so a deployment can verify what it fetched, the same way + # the installer publishes one. + 'X-Script-Sha256': digest, + }) diff --git a/plugins/computers/client/Report-AssetToShopDB.ps1 b/plugins/computers/client/Report-AssetToShopDB.ps1 new file mode 100644 index 00000000..d5f09d85 --- /dev/null +++ b/plugins/computers/client/Report-AssetToShopDB.ps1 @@ -0,0 +1,328 @@ +# Report-AssetToShopDB.ps1 +# +# Reports a PC's identity to ShopDB (Flask) so the computers/machines record +# stays current with whatever the PC actually is right now: hostname, BIOS +# serial, pc-type, logged-in user, DNC machine number (2001, 2002, ... when +# present) and its corp/AESFMA IPv4 address. +# +# TARGET: the ADR-006 collector API. +# POST /api/collector/computers +# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl, +# which Install-GEEnforce.ps1 provisions and the enforcement client already +# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in +# product code is a defect, and this script ships to every site. +# The computers plugin's apply_collector_payload upserts idempotently by +# hostname (create if missing, patch-style update if present - only the fields +# posted here change, so it never clobbers model/VNC/WinRM). Machine number maps +# to Asset.assetnumber; the placeholder 9999 is skipped server-side. +# +# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only +# covers the geenforce fetch/report endpoints). It needs a collector.ingest key, +# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB +# CollectorKey (same secret store the GE-Enforce client uses; provisioned at +# imaging), or overridden via the manifest entry's Args -ApiKey. Never bake the +# key into the manifest JSON on the share. +# +# Deployed in common\ (runs on EVERY shopfloor pc-type). Non-DNC bays report +# identity with no machineNo, so no PC-to-machine link is built - by design. +# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under +# the SYSTEM task. Always exits 0 so "last run result" stays clean; failures are +# logged, never thrown. +# +# WHY ONE NIC ONLY: +# Some bays carry two NICs - a private controller NIC and the routable +# corporate NIC. Only the routable one belongs in ShopDB. A site may name its +# corporate ranges (-AllowedRanges, or the CollectorRanges registry value); with +# none configured the NIC carrying the DEFAULT ROUTE is used, which expresses +# the same intent without knowing any site's addressing. + +param( + # Flask collector endpoint for the computers plugin. Empty resolves from + # HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves. + [string]$ApiUrl = '', + + # Comma-separated CIDRs naming this site's routable ranges, e.g. + # '10.20.0.0/23,10.21.4.0/26'. Empty uses the default-route NIC instead. + [string]$AllowedRanges = '', + + # collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret + # store in the registry. Override with -ApiKey via Args for testing. + [string]$ApiKey = '', + + [int]$TimeoutSec = 30 +) + +$ErrorActionPreference = 'Continue' + +# Force TLS 1.2 - older images default to SystemDefault which may negotiate a +# protocol the site rejects; the collector POST is HTTPS. +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { + New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +} +$logFile = Join-Path $logDir ('report-asset-{0}.log' -f (Get-Date -Format 'yyyyMMdd')) + +function Log([string]$msg) { + $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + "$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null +} + +# Server from the GE-Enforce config hive when not passed via Args. Any site +# running this script is running the enforcement client, which cannot work +# without BaseUrl, so it is present wherever this is deployed. +if (-not $ApiUrl) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $base = [string](Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction Stop).BaseUrl + if ($base -and $base.Trim()) { + $ApiUrl = $base.Trim().TrimEnd('/') + '/api/collector/computers' + break + } + } + } catch {} + } +} +if (-not $ApiUrl) { + Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.' + exit 0 +} + +# A site may name its routable ranges rather than rely on the default route. +if (-not $AllowedRanges) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name CollectorRanges -ErrorAction Stop).CollectorRanges + if ($v -and $v.Trim()) { $AllowedRanges = $v.Trim(); break } + } + } catch {} + } +} + +# collector key from the GE-Enforce secret store when not passed via Args. +if (-not $ApiKey) { + foreach ($regPath in @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')) { + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name CollectorKey -ErrorAction Stop).CollectorKey + if ($v -and $v.Trim()) { $ApiKey = $v.Trim(); break } + } + } catch {} + } +} +if (-not $ApiKey) { + Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.' + exit 0 +} + +# Routable ranges, if this site named any. NO SITE ADDRESSING IS BAKED IN: an +# unconfigured site falls through to the default-route NIC below (ADR-015). +# +# NOT named $allowedRanges: PowerShell variable names are case-insensitive, so +# that collides with the [string] parameter above and the array is silently +# COERCED to a string. .Count on a string is 1, so the script then believes a +# range is configured and never falls back to the default route. +$rangeList = @() +foreach ($cidr in ($AllowedRanges -split ',')) { + $cidr = $cidr.Trim() + if (-not $cidr) { continue } + $parts = $cidr -split '/' + if ($parts.Count -ne 2) { Log "WARN ignoring malformed range '$cidr'"; continue } + $rangeList += @{ Network = $parts[0].Trim(); PrefixLen = [int]$parts[1] } +} + +function ConvertTo-Uint32([string]$ip) { + $bytes = ([System.Net.IPAddress]::Parse($ip)).GetAddressBytes() + [Array]::Reverse($bytes) + return [BitConverter]::ToUInt32($bytes, 0) +} + +function Test-InAllowedRange([string]$ip) { + try { + $ipInt = ConvertTo-Uint32 $ip + foreach ($r in $rangeList) { + $netInt = ConvertTo-Uint32 $r.Network + $mask = [uint32]([math]::Pow(2, 32) - [math]::Pow(2, 32 - $r.PrefixLen)) + if (($ipInt -band $mask) -eq ($netInt -band $mask)) { return $true } + } + } catch {} + return $false +} + +Log '=== Report asset to ShopDB (collector) ===' + +# hostname - the collector identity field. required. +$hostname = $env:COMPUTERNAME + +# BIOS serial - optional now (collector keys on hostname). Sent when present. +$serialNumber = '' +try { + $serialNumber = (Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop).SerialNumber + if ($serialNumber) { $serialNumber = $serialNumber.Trim() } +} catch { + Log "WARN could not read BIOS serial: $($_.Exception.Message)" +} + +# Machine identifier - optional, sent only if found. Maps to Asset.assetnumber +# server-side (9999 placeholder skipped there and here). +# 1. eDNC registry (WOW6432Node, then native) - DNC/collections bays (2001...). +# 2. C:\Enrollment\cmm\cmmid.txt - CMM bay id (e.g. CMM3). +# 3. C:\Enrollment\machine-number.txt - imaging value. +# keyence / genspect / part-marker have no per-bay id -> no machineNo sent. +$machineNo = '' +foreach ($regPath in @( + 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', + 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' +)) { + if ($machineNo) { break } + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo + if ($v -and $v.Trim() -ne '9999') { $machineNo = $v.Trim() } + } + } catch { + Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)" + } +} +if (-not $machineNo) { + $cmmFile = 'C:\Enrollment\cmm\cmmid.txt' + if (Test-Path -LiteralPath $cmmFile) { + try { + $v = ([string](Get-Content -LiteralPath $cmmFile -First 1 -ErrorAction Stop)).Trim() + if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $cmmFile (CMM bay id): $machineNo" } + } catch { Log "WARN could not read ${cmmFile}: $($_.Exception.Message)" } + } +} +if (-not $machineNo) { + $mnFile = 'C:\Enrollment\machine-number.txt' + if (Test-Path -LiteralPath $mnFile) { + try { + $v = ([string](Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim() + if ($v -and $v -ne '9999') { $machineNo = $v; Log "machineNo from $mnFile (imaging value): $machineNo" } + } catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" } + } +} + +# OS name string (caption + feature-update + build), e.g. +# "Microsoft Windows 11 Enterprise 23H2 (build 22631)". Server upserts each +# distinct string into operatingsystems. +$osVersion = '' +$lastBootTime = '' +try { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop + $osVersion = "$($os.Caption)".Trim() + $displayVersion = '' + try { + $displayVersion = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -Name DisplayVersion -ErrorAction Stop).DisplayVersion + } catch {} + if ($displayVersion) { $osVersion += " $displayVersion" } + if ($os.BuildNumber) { $osVersion += " (build $($os.BuildNumber))" } + $osVersion = $osVersion.Trim() + # ISO 8601 - collector parses via datetime.fromisoformat. + try { $lastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-ddTHH:mm:ss') } catch {} +} catch {} + +# interactive console user. runs as SYSTEM, so use Win32_ComputerSystem.UserName +# (console session owner). empty when nobody logged on -> omitted so an +# unattended bay does not blank the last-known user. +$loggedInUser = '' +try { + $loggedInUser = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName + if ($loggedInUser) { $loggedInUser = ($loggedInUser -split '\\')[-1].Trim() } +} catch { + Log "WARN could not read logged-in user: $($_.Exception.Message)" +} + +# imaging pc-type (gea-shopfloor-*), read from the enrollment file. Sent only +# when present; absent -> server leaves existing pctype untouched (a bare report +# never re-types a PC). Unmapped values return a warning, not an error. +$pcType = '' +$ptFile = 'C:\Enrollment\pc-type.txt' +if (Test-Path -LiteralPath $ptFile) { + try { + $pcType = (Get-Content -LiteralPath $ptFile -First 1 -ErrorAction Stop).Trim() + } catch { Log "WARN could not read ${ptFile}: $($_.Exception.Message)" } +} + +# PC make/model from WMI. Server resolves/creates vendor + model. Sent only when +# present so a WMI read failure does not blank the model. +$manufacturer = '' +$model = '' +try { + $cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop + $manufacturer = "$($cs.Manufacturer)".Trim() + $model = "$($cs.Model)".Trim() +} catch { + Log "WARN could not read make/model: $($_.Exception.Message)" +} + +# The routable IPv4 - a physical, connected NIC. The collector schema takes a +# single ipaddress string, so report the corporate NIC and drop any +# controller/machine-LAN NIC. With ranges configured the IP must be in one; +# with none, the NIC carrying the default route is the routable one by +# definition, which needs no knowledge of a site's addressing. +$corpIp = '' +$defaultRouteIfIndexes = @() +if ($rangeList.Count -eq 0) { + try { + $defaultRouteIfIndexes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | + Sort-Object RouteMetric | + Select-Object -ExpandProperty InterfaceIndex -Unique) + } catch { + Log "WARN could not read the route table: $($_.Exception.Message)" + } +} +try { + $ipObjs = Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.IPAddress -notmatch '^169\.254' -and $_.IPAddress -ne '127.0.0.1' } + foreach ($ipo in $ipObjs) { + if ($corpIp) { break } + $adapter = $null + try { $adapter = Get-NetAdapter -InterfaceIndex $ipo.InterfaceIndex -ErrorAction Stop } catch {} + if (-not $adapter) { continue } + if (-not $adapter.HardwareInterface) { continue } + if ($adapter.Status -ne 'Up') { continue } + if ($rangeList.Count -gt 0) { + if (Test-InAllowedRange $ipo.IPAddress) { $corpIp = $ipo.IPAddress } + } elseif ($defaultRouteIfIndexes -contains $ipo.InterfaceIndex) { + $corpIp = $ipo.IPAddress + } + } +} catch { + Log "WARN interface enumeration failed: $($_.Exception.Message)" +} +if (-not $corpIp) { Log 'WARN no routable IPv4 NIC found; posting identity without ipaddress.' } + +# collector schema fields (lowercase concatenated). hostname is required; the +# rest are sent only when present so a partial read never blanks a good value. +$body = @{ hostname = $hostname } +if ($serialNumber) { $body['serialnumber'] = $serialNumber } +if ($machineNo) { $body['machinenumber'] = $machineNo } +if ($pcType) { $body['pctype'] = $pcType } +if ($manufacturer) { $body['vendorname'] = $manufacturer } +if ($model) { $body['modelnumber'] = $model } +if ($osVersion) { $body['osname'] = $osVersion } +if ($lastBootTime) { $body['lastboottime'] = $lastBootTime } +if ($loggedInUser) { $body['loggedinuser'] = $loggedInUser } +if ($corpIp) { $body['ipaddress'] = $corpIp } +$body['lastcheckin'] = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + +$json = $body | ConvertTo-Json -Compress -Depth 4 + +Log ("POST {0} host={1} serial={2} pcType={3} make={4} model={5} os={6} boot={7} machineNo={8} user={9} ip={10}" -f ` + $ApiUrl, $hostname, $serialNumber, $pcType, $manufacturer, $model, $osVersion, $lastBootTime, $machineNo, $loggedInUser, $corpIp) + +try { + $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json ` + -ContentType 'application/json' ` + -Headers @{ 'X-API-Key' = $ApiKey } ` + -TimeoutSec $TimeoutSec -ErrorAction Stop + Log ("RESPONSE {0}" -f ($resp | ConvertTo-Json -Compress -Depth 4)) +} catch { + Log "ERROR POST failed: $($_.Exception.Message)" +} + +exit 0 diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index 360ecc9b..cd724a09 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -39,6 +39,12 @@ export default [ meta: { requiresAuth: true, plugin: 'computers' } }, // Computer-specific settings + { + path: 'settings/collector', + name: 'collector-settings', + component: () => import('./views/CollectorSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, { path: 'settings/pctypes', name: 'pctypes', diff --git a/plugins/computers/frontend/views/CollectorSettings.vue b/plugins/computers/frontend/views/CollectorSettings.vue new file mode 100644 index 00000000..c530c76e --- /dev/null +++ b/plugins/computers/frontend/views/CollectorSettings.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue index 408dfbdd..5921fb41 100644 --- a/plugins/computers/frontend/views/PCForm.vue +++ b/plugins/computers/frontend/views/PCForm.vue @@ -245,6 +245,12 @@ + + +