PLUGINS.md listed 14 of the 15 bundled plugins. `backups` had no row: the
per-asset configuration backups the collector takes off shopfloor machines,
kind-pluggable, with view and download as separate permissions because a .reg
is a restorable artifact. Added, with the pointer that separates it from
BACKUP-RESTORE.md, which is the database and a different subject.
PLUGIN-LAB-PRINTEDPARTS said the finished implementation lives on branch
feat/printedparts-plugin. That branch was deleted in the v0.8.0 cleanup; only
the 17 lab-stage tags survived, and they are enough. Says so now, and warns
that the tags are a record of contract 0.11-0.13 rather than today's
conventions. Its manifest example carried core_version >=0.12.0 against a
shipped >=0.16.0.
The printer-assignment proposal still said the client script is not built. It
shipped and released, across v0.11.3 and v0.12.0, and the collector half that
contradicted the assigned-vs-observed split was retired in adc7ef4.
PROJECT-MAP regenerated for the test count.
339 lines
17 KiB
Markdown
339 lines
17 KiB
Markdown
# Proposal: assign printers to a machine in ShopDB, let the PC install them
|
|
|
|
Status: SHIPPED AND RELEASED. Built out 2026-08-18/19; the client script is
|
|
built too. The feature itself released in v0.11.3, the observation half and
|
|
`seed-from-observed` adoption in v0.12.0.
|
|
Author: planning session 2026-08-18.
|
|
|
|
One thing this proposal did NOT anticipate, resolved 2026-08-24 in `adc7ef4`:
|
|
the computers collector carried a `_sync_printer_links` that wrote
|
|
`defaultprinter` / `connectedto` rows straight from a PC report, which is the
|
|
ASSIGNED-vs-OBSERVED split this proposal rests on, broken from the other end.
|
|
It is retired; `defaultprinter` and `printers` are now ignored (not rejected) on
|
|
the collector payload. See [COLLECTOR-INTEGRATION.md](../COLLECTOR-INTEGRATION.md).
|
|
|
|
## 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. The printers belong to the MACHINE, not
|
|
to the box currently driving it: tick the printers that belong on the machine,
|
|
mark one default, and the assignment reaches whichever PC controls that machine.
|
|
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 point of putting the assignment on the machine is that a reimaged PC needs
|
|
no backup and no restore step. The asset register is the source of truth, and a
|
|
replacement PC that inherits the `controls` edge inherits the printers with it.
|
|
|
|
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 that bay have" is a
|
|
question ShopDB can answer, and today it cannot.
|
|
- **A reimage stops costing a visit.** The bay reinstalls its own printers, from
|
|
the machine's record, with nothing saved off the old PC.
|
|
- **Swapping the PC keeps the printers.** They were never the PC's.
|
|
- **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` |
|
|
| Propagation mechanism | `RelationshipTypePropagation` (ADR-001): "type X propagates through connections of type Y" |
|
|
| A working precedent | `resolve_asset_position` walks `partof` then `controls` to give a PC the machine's map position |
|
|
| 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. The data model
|
|
|
|
### 4.1 One new relationship type
|
|
|
|
`usesprinter`, directional, source -> printer, meaning "this printer is
|
|
installed here". It is seeded next to `defaultprinter`, which already exists and
|
|
means "which of them is the default". Both are seed data, not a migration, which
|
|
is how every other relationship type shipped.
|
|
|
|
### 4.2 Two propagation rails, consumed at READ time
|
|
|
|
`usesprinter` propagates through `controls`, and so does `defaultprinter`. Both
|
|
are rows in `relationshiptypepropagations`, the same mechanism map positions
|
|
use. Inventing a second mechanism for this was the alternative, and it was
|
|
rejected.
|
|
|
|
The rails are inert at write time on purpose. The create-time fan-out
|
|
(`propagate_relationship`) skips directional through-types, and `controls` is
|
|
directional, so assigning a printer to a machine does not copy rows onto its PC.
|
|
The walk happens when something asks, which is what makes the next rule possible.
|
|
|
|
### 4.3 Resolution order for a PC
|
|
|
|
1. The PC's OWN active `usesprinter` / `defaultprinter` rows, if it has any.
|
|
2. Otherwise, one hop out along its `controls` edges to the machines it drives,
|
|
and those machines' rows instead, tagged as inherited.
|
|
|
|
Own beats inherited, whole set at a time: a PC with its own assignment is
|
|
overriding the bay, not adding to it. An office PC controls no machine and still
|
|
works, because step 1 is the normal case for it.
|
|
|
|
The override is a real trap and the UI has to say so. A tech who "fixes" a bay
|
|
by editing the PC has shadowed the machine's record, and the machine will keep
|
|
disagreeing until someone clears the PC's own rows.
|
|
|
|
### 4.4 One default, optional, and never dangling
|
|
|
|
The unique constraint is `(source, target, type)`, which happily accepts two
|
|
different defaults. So the rule is enforced in the API on write:
|
|
|
|
- Exactly one `defaultprinter` per asset. Setting a default replaces the
|
|
existing one.
|
|
- A default is OPTIONAL. A bay with three printers and no default is valid.
|
|
- The default must be one of the assigned printers. Unassigning the printer that
|
|
is currently default clears the default rather than leaving it dangling.
|
|
|
|
### 4.5 One column on `printerdrivers`
|
|
|
|
`drivername` - the driver's exact name as the INF declares it, e.g.
|
|
`HP Universal Printing PCL 6`. `Add-PrinterDriver` matches on that string, not
|
|
on `name`, which is ours to choose, 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.
|
|
|
|
It is a plugin-chain migration (`printers0003drivername`), nullable, guarded so a
|
|
re-run is a no-op. `printerdrivers` was created by a core migration but its DDL
|
|
moved to the printers chain at the ADR-008 cutover.
|
|
|
|
The `installmethod` column (`pnputil` or `dpinst`) proposed earlier is NOT being
|
|
built. See section 8: if production confirms no Brother printers, everything is
|
|
`pnputil` and the column has no second value to hold.
|
|
|
|
## 5. What has to be built
|
|
|
|
### 5.1 A resolution helper in core
|
|
|
|
The read-time walk of section 4.3, beside `resolve_asset_position` and exported
|
|
on the `shopdb.api` contract surface (an additive minor bump). It has to live in
|
|
core because `RelationshipTypePropagation` is not on the contract surface, and a
|
|
plugin may not reach past it (ADR-002).
|
|
|
|
The through-type comes from the seeded rails, not from a hardcoded `'controls'`,
|
|
so a site that adds a rail gets the behaviour without a code change.
|
|
|
|
### 5.2 Two endpoints
|
|
|
|
```
|
|
GET /api/printers/for-host/<hostname>
|
|
```
|
|
|
|
The desired printer set for one PC, resolved per section 4.3, each entry with
|
|
what a client needs to install it: queue name, host or IP, port, driver name,
|
|
driver location, and which one is default.
|
|
|
|
Resolved by hostname, not machine number: the collector already upserts PCs by
|
|
hostname, and an office PC has no machine number. Matched case-insensitively -
|
|
`COMPUTERNAME` is uppercase and MySQL forgives that where SQLite does not.
|
|
|
|
An unknown host, a site without the computers plugin, or nothing assigned all
|
|
return an empty set. That is the client's designed no-op and it must stay
|
|
indistinguishable from "assigned nothing".
|
|
|
|
```
|
|
PUT /api/printers/assignments/for-asset/<asset_id>
|
|
```
|
|
|
|
The whole assignment for one asset - machine or PC - in one call:
|
|
`{printerassetids: [...], defaultprinterassetid: N|null}`. It reconciles rather
|
|
than inserting: rows that went away are soft-deleted, rows that come back
|
|
REACTIVATE the soft-deleted row (the unique constraint spans inactive rows, so a
|
|
blind insert is an integrity error on assign, unassign, re-assign), new rows are
|
|
created, and the single default is replaced.
|
|
|
|
The rules in 4.4 hold here or nowhere. Row-at-a-time writes through the generic
|
|
relationships path leave two-default windows and know nothing of the subset rule.
|
|
|
|
**Removing an assignment NEVER uninstalls anything.** Server-side the row simply
|
|
goes: no cascade, no side effects, nothing queued for the client to undo.
|
|
|
|
**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`.
|
|
|
|
### 5.3 UI on the PC form
|
|
|
|
A printer multi-select plus a default dropdown whose options are only the
|
|
currently selected printers, clearing itself when its printer is deselected.
|
|
Saved through the reconcile endpoint against the PC's asset.
|
|
|
|
The computers plugin does not depend on the printers plugin and must not start:
|
|
the section hides itself when the printers API is not there.
|
|
|
|
The machine-side picker is out of scope for now, which means the machine's
|
|
assignment is editable only through the generic relationships card. That is the
|
|
side the design says is primary, so it is the obvious next piece of UI.
|
|
|
|
### 5.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.
|
|
|
|
## 6. Desired state and observed state are not the same thing
|
|
|
|
Everything above is DESIRED state: what SHOULD be installed on a PC. Nothing in
|
|
this feature knows what IS installed on it. The client reads the desired state,
|
|
converges toward it, and reports nothing back.
|
|
|
|
Reporting the observed state is the obvious next feature and is deliberately not
|
|
this one. If the collector sent the installed queues per host - name, port,
|
|
driver, which is default - then comparing that against the resolved assignment
|
|
gives drift detection for free: "this bay is missing the label printer", "this
|
|
PC has three queues nobody assigned", "the default is not the assigned one".
|
|
|
|
Keeping them apart is a rule, not a preference:
|
|
|
|
- **Observed data never writes `usesprinter` rows.** A register that learns from
|
|
what it finds mirrors the drift instead of correcting it, and the fault
|
|
becomes the desired state.
|
|
- **Observed data belongs on the computer record, timestamped**, like the rest
|
|
of the collector payload. It is an observation with an age, not a decision.
|
|
- **An empty answer from the API means "nothing assigned", not "nothing
|
|
installed"**, which is exactly why section 5.2 refuses to make removal
|
|
uninstall anything.
|
|
- The two can disagree indefinitely and that is a report to read, not an error
|
|
to resolve automatically.
|
|
|
|
## 7. Decisions to take before writing the client
|
|
|
|
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.
|
|
|
|
Open on the server side, and each one changes the response contract:
|
|
|
|
4. **How a universal driver resolves.** `PrinterDriver` links to a printer by
|
|
exact `modelnumberid`, and the target state is roughly four rows dominated by
|
|
HP UPD and Xerox GPD, which match no single model. Either the driver row
|
|
gains a vendor, or a `modelnumberid IS NULL` row matches on the printer's
|
|
resolved vendor name. Until this is settled, `for-host` returns no driver for
|
|
41 of 44 printers.
|
|
5. **What `port` means when it is null.** RAW 9100 is the obvious default; whose
|
|
job it is to apply it - server or script - has to be written down once.
|
|
6. **Who may read `for-host`.** `pc-default` and `install-list` are anonymous;
|
|
the collector and the GE-Enforce fetch use scoped service tokens. This one
|
|
discloses per-PC configuration keyed by hostname.
|
|
7. **Two inherited defaults.** A PC can legitimately control both bays of a
|
|
dual-bay machine, or several machines. The union of assigned printers is
|
|
easy; the default needs a deterministic rule, or none when it is ambiguous.
|
|
8. **Legacy `defaultprinter` rows have no `usesprinter` row**, because they
|
|
predate the type. Either an active default implies assignment on read
|
|
(zero-touch, preferred) or a one-time backfill writes the missing rows.
|
|
Otherwise existing defaults vanish from `for-host` while still showing in
|
|
`pc-default`.
|
|
9. **Deletions through the generic relationships card bypass the reconcile
|
|
endpoint** and can strand an active default pointing at an unassigned
|
|
printer. Either the resolver drops dangling defaults or the core delete path
|
|
learns the rule.
|
|
|
|
## 8. 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 - and with them the `installmethod` column.
|
|
- **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. Each needs `drivername` copied verbatim from its INF.
|
|
Nothing in this proposal works until a printer can resolve to a driver.
|
|
|
|
## 9. 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.
|
|
|
|
## 10. Upgrading an existing site
|
|
|
|
Three steps, and the third is the one that gets forgotten:
|
|
|
|
1. `flask db upgrade` - no core migration in this feature, but a deploy runs it.
|
|
2. `flask plugin upgrade-all` - applies `drivername`. Skipping it is the classic
|
|
1054 unknown-column error.
|
|
3. `flask seed reference-data` - REQUIRED. Without it the `usesprinter` type and
|
|
both propagation rails do not exist, and `for-host` resolves nothing, quietly,
|
|
because empty is also the healthy answer.
|
|
|
|
Pair the upgrade with a smoke check against a known bay. A site with reversed
|
|
legacy `controls` rows (machine -> PC) should run
|
|
`flask relationships fix-controls-direction` first, or inheritance resolves for
|
|
none of those PCs.
|
|
|
|
## 11. 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. Section 6 would change it; this feature does not.
|
|
- 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.
|