Generate the collector script per site, and bring EventSaver into the repo
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s

A site adopting ShopDB had to be handed two files and told what to edit in them.
Both are now the product's, and one of them the server writes for you.

GET /api/computers/client-script (admin) returns Report-AssetToShopDB.ps1 with
this site's values already in it: site_base_url becomes the -ApiUrl default and
the new computers_routableranges setting becomes -AllowedRanges. Only the
PARAMETER DEFAULTS are substituted - the copy in plugins/computers/client/ stays
runnable, so there is no second version to drift from the first - and everything
stamped stays overridable by argument or registry, because a bay may need to
differ from its site. Settings > Computers > Asset reporter edits the ranges,
downloads the script and shows its SHA-256.

The collector key is deliberately not stamped in, and a test fails if it ever
is. That file lands on every shop-floor PC, and a token spread across hundreds
of bays cannot be rotated quietly; it stays in the registry, provisioned per
ADOPTING-AT-ANOTHER-SITE.md.

The routable ranges are the last thing that was hardcoded in that script. They
are now a setting, so West Jefferson's two CIDRs move out of source code and
into that site's own configuration - which is what ADR-015 asks for - and a site
that sets nothing still works, because the script falls back to the NIC carrying
the default route.

EventSaver joins it in plugins/slides/client/, source only: EventSaver.cs and
EventSaver.ini, no compiled .scr - a binary is a release asset, like the
installer exe. The share path that was compiled into Config.Folder is gone. It
used to be the fallback when the ini was missing, which silently pointed a new
site at the reference site's file server; it is now empty, and failing visibly
beats displaying another site's slides. Verified by compiling the edited source
in the Windows VM with the in-box csc.exe: 15,872 bytes, exit 0.

Also: the DSC example in the adoption guide gains a CollectorRanges resource and
stops passing -ApiUrl to a script that already reads BaseUrl from the registry
the same example writes, and the guide points at the generated download instead
of hand-editing a URL.

The contract test caught the endpoint importing shopdb directly for the version
string, which ADR-002 forbids a plugin from doing. The product and contract
versions are in app.config now, which a plugin reads through current_app.

Adds docs/proposals/printer-assignment.md: assign printers to a PC in ShopDB and
let the bay install them, with what the fleet data says about drivers - HP and
Xerox cover 41 of 44 printers with universal drivers, there are no Brother
printers at all despite 208 files of Brother inkjet drivers in the installer,
and printerdrivers holds one row pointing at a per-model folder instead of a
universal driver.
This commit is contained in:
cproudlock
2026-08-18 15:51:14 -04:00
parent 96f127f8c8
commit 2083029ff2
13 changed files with 1169 additions and 16 deletions

View File

@@ -23,11 +23,11 @@ at all yet.
|---|---|
| GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. |
| `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. |
| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). |
| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). |
Ask the maintainers for the two that are not here. A site that would rather not
run a binary it cannot rebuild should take EventSaver's source and compile it
locally - the build needs no SDK and is one command.
A site that would rather not run a binary it cannot rebuild should take
EventSaver's source and compile it locally - the build needs no SDK and is one
command.
---
@@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone.
| Input | Where it comes from |
|---|---|
| Server URL | `-ApiUrl https://<your-shopdb>/api/collector/computers` |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` |
| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it |
| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script |
| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites |
**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter
generates this script with your server's URL and ranges already in the parameter
defaults, and shows its SHA-256. The key is deliberately not included.
### Minting the key on your ShopDB
@@ -142,13 +147,27 @@ Configuration ShopdbCollector
Ensure = 'Present'
}
# Optional. Only for a site whose bays carry both a controller NIC and a
# corporate one AND whose default route is not the corporate NIC. Leave
# this resource out otherwise - the script picks the default-route NIC.
Registry CollectorRanges
{
Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB'
ValueName = 'CollectorRanges'
ValueData = '10.20.0.0/23,10.21.4.0/26'
ValueType = 'String'
Ensure = 'Present'
}
Script ReportingTask
{
GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } }
TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) }
SetScript = {
# No -ApiUrl: BaseUrl above is where the script reads it from,
# and one source beats two that can disagree.
$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"'
-Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"'
$trigger = New-ScheduledTaskTrigger -Daily -At 7am
Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action `
-Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force

View File

@@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor.
**Folder mode** predates the server and stays as a fallback for a site with no
ShopDB instance yet, or for content nobody wants in the database.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the
> binary falls back to a path compiled into `EventSaver.cs` - and that path
> belongs to the site it was first built for. Ship the ini. A missing ini is not
> a neutral default.
> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is
> no source to read and the screensaver shows nothing. That is deliberate: the
> compiled-in fallback used to be the path of the site it was first built for, so
> a missing ini silently pointed a new site at someone else's file server. It is
> now empty, and failing visibly beats displaying the wrong site's slides. Ship
> the ini.
## What decides the running order
@@ -98,8 +100,9 @@ covering something someone needed to see.
## Building it
No SDK required - it compiles with the in-box .NET Framework compiler on any
Windows 10 or 11 machine:
The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it
compiles with the in-box .NET Framework compiler on any Windows 10 or 11
machine:
```
C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^

View File

@@ -1697,6 +1697,14 @@
"params": "protocol_id in path; body: any of protocolname, port, isactive",
"purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against",
"example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3"
},
{
"method": "GET",
"path": "/api/computers/client-script",
"auth": "jwt + role:admin",
"params": "none",
"purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched",
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script"
}
],
"surface": "plugin-computers"

View File

@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "ShopDB Flask API",
"version": "0.10.0",
"version": "0.11.2",
"description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither."
},
"servers": [
@@ -9892,6 +9892,44 @@
}
}
},
"/api/computers/client-script": {
"get": {
"tags": [
"plugin-computers"
],
"summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...",
"description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/measuringtools/types": {
"get": {
"tags": [

View File

@@ -0,0 +1,161 @@
# Proposal: assign printers to a PC in ShopDB, let the PC install them
Status: PROPOSED. Not built.
Author: planning session 2026-08-18.
## 1. What this is
Today a printer reaches a shop-floor PC because a person walks up to it, opens
the printer installer, finds the printer on a floor plan and clicks it. That is
fine for someone choosing a printer, and wrong for a bay whose printers are a
property of the bay.
This proposal makes the assignment data: edit a PC in ShopDB, tick the printers
that belong on it, mark one default. The PC converges on its next GE-Enforce
cycle - installing what is missing and setting the default - and keeps
converging, so a reimaged bay comes back with its printers and a bay that drifts
is corrected.
The map installer stays, for the case it is actually good at: a person at an
unmanaged or office PC picking a printer that nobody assigned.
## 2. Why it is worth doing
- **The assignment becomes a record.** "Which printers does bay 2107 have" is a
question ShopDB can answer, and today it cannot.
- **A reimage stops costing a visit.** The bay reinstalls its own printers.
- **Drift is corrected, not just detected.** A queue deleted by a user comes
back.
- **It removes the walk-up from the common case.** The installer's map remains
for the uncommon one.
## 3. What already exists
Most of the model is in place, which is why this is a small feature rather than
a project.
| piece | state |
|---|---|
| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` |
| Default lookup | `GET /api/printers/pc-default?machine=NNNN` |
| Host lookup | `GET /api/computers/by-hostname/<hostname>` |
| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site |
| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) |
| Per-printer install path | `Printer.installpath` |
| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` |
| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle |
| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` |
## 4. What has to be built
### 4.1 One endpoint
```
GET /api/printers/for-host/<hostname>
```
Returns the printers assigned to that PC and which is default, each with what a
client needs to install it: queue name, host or IP, port, driver name, driver
location.
Resolved by hostname, not machine number: the collector already upserts PCs by
hostname, and an office PC has no machine number.
**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope
and PC type and sync broadly; putting per-PC rows there would leak every bay's
configuration to every bay and grow without limit. One manifest entry runs one
script that asks the API what THIS host gets - the mirror image of
`Report-AssetToShopDB.ps1`.
### 4.2 Two columns on `printerdrivers`
- `drivername` - the driver's exact name as the INF declares it, e.g.
`HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, and a
mismatch is the usual failure. Deriving it by parsing the INF on hundreds of
bays is fragile; a human confirming it once in ShopDB is not.
- `installmethod` - `pnputil` or `dpinst`. See section 6: if Brother really is
absent from the fleet, everything is `pnputil` and this column can wait.
### 4.3 UI on the PC form
A printer picker writing `defaultprinter` (one) and an assignment list (many).
`AssetRelationships.vue` already edits relationships; this is a narrowed case of
it.
### 4.4 One client script, in two contexts
`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the
contract it consumes, and run as a manifest entry with `DetectionMethod=Always`.
*As SYSTEM, every cycle:*
1. `GET /api/printers/for-host/$env:COMPUTERNAME`
2. For each assigned printer with no queue: trust the driver catalog's cert,
`pnputil /add-driver`, create the port, create the queue
3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter`
4. Ensure the per-user task exists
*In the user's context, at logon and on a repeat:*
5. Read that value, compare with the current default, set it if it differs, and
clear "Let Windows manage my default printer" - otherwise Windows silently
overrides the choice the next time someone prints elsewhere
The default printer is per-user state, which is the only reason this needs two
contexts. Everything else is machine state and belongs to the cycle.
Converge, do not reinstall: when the state matches, the script does nothing.
Nothing here needs the manifest to know when a printer changes, because the
desired state is fetched, not declared.
## 5. Decisions to take before writing code
1. **Never remove a queue by default.** A transient API failure would otherwise
strip printers fleet-wide. Deletion is an explicit opt-in, per PC.
2. **Enforced or set-once for the default?** Re-applying every cycle overrides a
user who chose their own default - correct for a locked bay, irritating on an
office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag
rather than one global answer.
3. **Failure is silent and safe**: unreachable API means change nothing, log,
exit 0 - the convention `Report-AssetToShopDB.ps1` already follows.
## 6. What the fleet data says, and the one prerequisite
The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1.
- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD,
Xerox Global Print Driver). One driver record each serves every queue of that
make.
- **There are no Brother printers at all**, yet the installer carries 208 files
of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices
with no Printer-class INF, which is the only reason a second staging method
(DPInst) exists. If production confirms no Brother, that payload and that code
path can both go.
- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is
a fourth special case - a PostScript device the UPD does not cover.
**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it
points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the
universal driver - the opposite of how a UPD should be used. The table needs
roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its
payload is restored. Nothing in this proposal works until a printer can resolve
to a driver.
## 7. Deployment constraint that shapes the design
**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching
a share path must run as a manifest entry inside that cycle, never as its own
scheduled task. The failure is silent - the task reports 0 processed, 0
installed, 0 failed - and it has cost a session before.
This is why driver staging belongs in the cycle even though the per-user default
does not, and why "the assignment script schedules a task that installs drivers"
is the wrong shape.
## 8. What this does not change
- The printer installer keeps working, for walk-up and self-service.
- Nothing about how printers are modelled, mapped or reported.
- The collector contract.
- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or
a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script.