Move the documentation to the line that ships, and correct it against the code
The docs lived on one lineage and the fleet code on another. That is why six pages described a shared fleet key as how an agent authenticates, why COLLECTOR-AUTH had to warn that the per-PC credential code was not in the repository it sat in, and why ADR-019 recorded a decision whose implementation was somewhere else. Documentation that cannot see the code it documents goes stale by construction, and no amount of editing fixes that. So the corpus moves here: 84 files, 56 pages, 20 ADRs, the proposals, INDEX.md, llms.txt and both gates. Filenames are unchanged, because a wiki page name is derived from the filename and a rename breaks every link anybody saved. THE POINT OF THE MOVE IS THE CORRECTION IT ENABLES. Every caveat that said "not in this tree" was false the moment the files landed beside the code, so each was rewritten from what is actually here: - COLLECTOR-AUTH gained the real enroll request body, the two bounds on the unauthenticated route (per-IP rate limit, pending cap with stale rows dropped before a real PC is refused), and the rejected pending reason that binds to the endpoint identity rather than the nonce. - ADR-019 now describes an implementation that is present, including its tests, and says plainly that what remains is the FLEET migration, not the code. - proposals/device-identity claimed phase 1 was unbuilt. plugins/computers declares deviceid on the collector schema and prefers it over the older fallbacks. Corrected in the page, its summary and the index. WINDOWS/IIS IS NOW DOCUMENTED AS THE LEGACY PATH, per the product decision, and carefully: it is what production runs today. Every Windows page opens by saying it is supported, not for a new site, and where a new site should go instead. No procedure was cut - an operator on an IIS box at 2am needs these pages to work exactly as well tomorrow. MIGRATE-TOPOLOGY stops being a footnote and becomes the off-ramp, which is why it nearly doubled: what was there did not match how the Docker stack really works here. The Docker material that already lived on this line was kept and given ownership rather than buried under the ported Windows-centric prose: DEPLOY now describes the four services, the migrate service that runs the upgrade chain before api starts, TLS terminated by nginx, and the plugin count corrected from thirteen to seventeen - a number both lineages had wrong. ROADMAP is rewritten from evidence rather than memory: what has shipped, read out of the changelog, the ADRs and the manifests, and ahead of it exactly two items - finalize a distributed release and polish the setup wizard. PROJECT-MAP is generated and was stale; the GENERATOR was what was wrong. --check passes now. API-REFERENCE is retired to a pointer. /api/docs serves 573 operations with derived auth, query parameters and harvested examples, and a hand-written page competing with that is a liability. The file stays so its wiki page and its inbound links survive. AND THE MOVE TURNED ON A SCRUB THAT HAD NEVER RUN HERE. test_docs_publishable keys on docs/*.md, which this line did not have, so it skipped - its own comment predicted what would surface. Porting the corpus activated it and it found 48 lines that must not reach a published wiki: branch names abbreviating the site, assistant-tooling paths, the site name in two deployment pages, and an employee ID in a Windows path that the scrub does not even look for.
This commit is contained in:
29
docs/proposals/README.md
Normal file
29
docs/proposals/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
<!-- docmeta
|
||||
purpose: reference
|
||||
audience: developer
|
||||
status: current
|
||||
summary: What each proposal designed, whether it was built, and which ADR decided it.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# Proposals
|
||||
|
||||
A proposal is a design written before the work, kept afterwards as the record of
|
||||
why the thing looks the way it does. A proposal is NOT a decision record: where
|
||||
a proposal and an ADR disagree, the ADR wins.
|
||||
|
||||
**None of these is current guidance.** Read one to understand why something
|
||||
exists, not to learn how it works today - for that, read the page named in the
|
||||
right-hand column.
|
||||
|
||||
| Proposal | What it designed | Built? | Read instead |
|
||||
|---|---|---|---|
|
||||
| [dashboard-live-fleet](dashboard-live-fleet.md) | A dashboard that shows what needs doing instead of row counts | Yes | [ADR-010](../adr/ADR-010-frontend-plugin-hooks.md) for the contract it introduced |
|
||||
| [device-identity](device-identity.md) | One durable identity for the device a PC drives, ending in the registry | Phase 1 built (the collector `deviceid` field). Phases 2 and 3 not started | The proposal itself, for the remaining phases |
|
||||
| [ge-enforce-plugin](ge-enforce-plugin.md) | GE-Enforce manifests as ShopDB data, served over HTTP | Yes | [ADR-012](../adr/ADR-012-geenforce-manifest-ownership.md), then [GE-ENFORCE](../GE-ENFORCE.md) |
|
||||
| [printedparts-plugin](printedparts-plugin.md) | A 3D-printed-parts storefront, bin labels and a kiosk | Yes, as `plugins/printedparts/` | [PLUGIN-LAB-PRINTEDPARTS](../PLUGIN-LAB-PRINTEDPARTS.md) |
|
||||
| [printer-assignment](printer-assignment.md) | Printers as a property of the machine, installed by the PC | Yes, v0.11.3 and v0.12.0 | [PRINTER-INSTALLER](../PRINTER-INSTALLER.md), [COLLECTOR-INTEGRATION](../COLLECTOR-INTEGRATION.md) |
|
||||
|
||||
A proposal that is adopted says so in its own status line, and names what
|
||||
adopted it. A proposal nobody built stays here anyway: the alternatives it
|
||||
rejected are the part worth keeping.
|
||||
179
docs/proposals/dashboard-live-fleet.md
Normal file
179
docs/proposals/dashboard-live-fleet.md
Normal file
@@ -0,0 +1,179 @@
|
||||
<!-- docmeta
|
||||
purpose: explanation
|
||||
audience: developer
|
||||
status: historical
|
||||
summary: The 2026-08 case for a worst-first dashboard queue. Built; kept as the record of why.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# Proposal: a dashboard that shows the fleet, not the row count
|
||||
|
||||
- Status: IMPLEMENTED (a proposal, not a decision record - the
|
||||
contract it introduced is ADR-010 and contract 0.19.0)
|
||||
- Date: 2026-08-11
|
||||
- Author: ShopDB maintainers
|
||||
- Relates to: ADR-010 (frontend plugin hooks), ADR-013 / ADR-014 (lean per-site builds), ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership)
|
||||
|
||||
## The problem
|
||||
|
||||
The dashboard reports totals: assets, machines, PCs, network devices, printers,
|
||||
measuring tools. Those numbers are true every day and actionable on none of
|
||||
them. Nobody opens the page to learn there are 417 assets.
|
||||
|
||||
Meanwhile the system already knows things worth acting on and shows them
|
||||
nowhere:
|
||||
|
||||
- a bay returned 500 to every collector report for a day and a half, and the
|
||||
only evidence was a log file on the PC
|
||||
- eleven part markers' backups were overwriting each other for weeks
|
||||
- a PC can stop reporting entirely and nothing says so
|
||||
- GE-Enforce records enforcement failures, stale manifests and repeated
|
||||
self-heals, and none of it surfaces outside a report modal
|
||||
|
||||
Every one of those was found by someone going to look. The dashboard should be
|
||||
where they find you.
|
||||
|
||||
## What already exists
|
||||
|
||||
Two discoveries shaped this proposal.
|
||||
|
||||
**The plugin mechanism is built and dead.** `BasePlugin.get_dashboard_widgets`
|
||||
exists, `GET /api/dashboard/widgets` aggregates across enabled plugins, skips
|
||||
disabled ones, isolates a broken plugin in production and sorts by position.
|
||||
Five plugins - notifications, network, machines, computers, printers - already
|
||||
declare widgets.
|
||||
|
||||
**Nothing consumes it.** The frontend never calls the endpoint, and not one of
|
||||
the declared components (`PrinterStatusWidget`, `ComputerStatusWidget`,
|
||||
`MachineStatusWidget`, `NotificationsWidget`, `NetworkStatusWidget`) exists.
|
||||
The declarations point at components that were never written.
|
||||
|
||||
So this is less "build a dashboard" than "finish one", plus a contract fix.
|
||||
|
||||
**The data is already collected.** Nothing here needs a new collector field:
|
||||
|
||||
| Signal | Source |
|
||||
|---|---|
|
||||
| PC reporting / silent | `computers.lastreporteddate` |
|
||||
| Machine has a controlling PC | `controls` links, `collector:machine` |
|
||||
| Backed up, stale, never | `backuprevisions` per asset and kind |
|
||||
| Enforcement failed | `manifestenforcementreports.status`, `failedcount` |
|
||||
| Which entry failed, and why | `manifestenforcementresults.action`, `exitcode`, `message` |
|
||||
| Stale manifest | report `appliedversion` versus the scope's published version |
|
||||
| Drift that will not stick | repeated `selfhealed` results for one entry |
|
||||
| Enforcer version spread | `manifestenforcementreports.enforcerversion` |
|
||||
| Installed software and versions | `computerinstalledapps` |
|
||||
| Toner critical / low | printer supplies, Zabbix-fed |
|
||||
| Warranty expiring | warranty plugin |
|
||||
| Mis-numbered bays | the `check-shared-machines` query |
|
||||
|
||||
## Proposed change to the widget contract
|
||||
|
||||
The existing contract names a **component per widget**. That does not scale and
|
||||
does not survive a lean build: every widget needs a bespoke Vue component, and a
|
||||
plugin-provided component has to be staged into the frontend bundle to exist at
|
||||
all.
|
||||
|
||||
ADR-010 already solved this for asset panels: a plugin declares `render: 'list'`
|
||||
or `'keyvalue'` or `'tabs'` plus a `map` of fields, and a GENERIC core component
|
||||
renders it. Do the same here. A plugin declares data and shape; core owns the
|
||||
rendering.
|
||||
|
||||
```python
|
||||
{
|
||||
'id': 'geenforce-failures',
|
||||
'title': 'Enforcement failures',
|
||||
'endpoint': '/api/geenforce/dashboard/failures',
|
||||
'render': 'exceptions', # generic renderer
|
||||
'severity': 'critical', # critical | warning | info
|
||||
'permission': 'geenforce.view',
|
||||
'position': 10,
|
||||
'empty': 'hide', # hide | line
|
||||
'map': {
|
||||
'title': 'hostname',
|
||||
'detail': 'entryname',
|
||||
'meta': [{'key': 'message'}, {'key': 'exitcode'}],
|
||||
'link': '/pcs/{computerid}',
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Three renderers cover everything listed above:
|
||||
|
||||
- `exceptions` - a list of things that are wrong, each linking to the thing
|
||||
- `metric` - a single number with a trend or threshold, for the cases where the
|
||||
count IS the story (toner critical: 3)
|
||||
- `list` - recent items, for notifications and KB adds
|
||||
|
||||
## Principles
|
||||
|
||||
**An exception board, not a stat board.** The default view is what needs a
|
||||
person today. A count appears only where the count is the story.
|
||||
|
||||
**Empty cards must shrink.** A card that says "nothing wrong" every day trains
|
||||
people to stop reading the page. That is exactly how `ntlars-backup.log` reached
|
||||
3,234 lines with 17 that mattered. `empty: 'hide'` is the default; `'line'` for
|
||||
the few where absence is itself news.
|
||||
|
||||
**Every row links to the thing.** A dashboard that says three printers are low
|
||||
without linking to them is a worse version of a report.
|
||||
|
||||
**Cards respect RBAC.** A card declares a `permission`; a user without it never
|
||||
sees the card or its endpoint. Toner levels are not sensitive, but "which PCs
|
||||
expose VNC" is, and the dashboard must not become a way around role gating.
|
||||
|
||||
**Thresholds are settings with neutral defaults** (ADR-015): quiet window, toner
|
||||
percentage, warranty horizon. Every site will disagree with the numbers.
|
||||
|
||||
**One endpoint per card, lazily loaded, failing independently.** A hung Zabbix
|
||||
call must not blank the page.
|
||||
|
||||
## Wave one
|
||||
|
||||
Cards whose data is already reliable, ordered by how actionable they are:
|
||||
|
||||
1. **Enforcement failures** - current reports with `status = failed`, showing
|
||||
the failing entry and exit code.
|
||||
2. **PCs not reporting** - `lastreporteddate` beyond the quiet window, and PCs
|
||||
with no GE-Enforce report at all, which is a different and worse case.
|
||||
3. **Backups missing or stale** - machines with no revision of a kind their
|
||||
PC type should produce, and revisions older than the interval.
|
||||
4. **Toner critical, then low** - links to the printer, then to its admin page.
|
||||
5. **Warranties expiring** - within the configured horizon.
|
||||
6. **Mis-numbered bays** - `check-shared-machines`, promoted from a CLI command
|
||||
nobody will remember to run.
|
||||
|
||||
Then, lower and quieter: stale manifest versions, repeated self-heals, enforcer
|
||||
version spread, active notifications, recent application and KB additions.
|
||||
|
||||
## Wave two: desired versus observed
|
||||
|
||||
The highest-value card and the most work. The manifest declares what a PC type
|
||||
should have; `computerinstalledapps` records what it does; `filters.py` already
|
||||
decides which entries apply to a given PC. Diffing those answers "which bays are
|
||||
missing something they should have" - which nothing in the system answers today,
|
||||
and which is the thing GE-Enforce exists to guarantee.
|
||||
|
||||
Deferred to wave two because it needs the resolver wired into a query path, and
|
||||
because wave one needs nothing new.
|
||||
|
||||
## Risks
|
||||
|
||||
**Aggregate queries on the most-visited page.** These are fleet-wide scans on
|
||||
every load. Cache 30-60 seconds, and log slow cards - a lazily-loaded card can
|
||||
be slow for months before anyone mentions it.
|
||||
|
||||
**Cards become the new noise.** Six cards that are always empty are as useless
|
||||
as one number that never changes. If a card is empty for a month, delete it.
|
||||
|
||||
**Plugin gating must be real.** A lean site without `printers` must render no
|
||||
toner card. The existing endpoint already skips disabled plugins; the frontend
|
||||
must not hardcode a card list alongside it.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Per-user dashboard customisation. Later, if asked for.
|
||||
- Historical charting. This is a "what needs doing now" board; trends belong in
|
||||
reports.
|
||||
- Replacing the shopfloor TV dashboard, which is a different audience with
|
||||
different needs.
|
||||
235
docs/proposals/device-identity.md
Normal file
235
docs/proposals/device-identity.md
Normal file
@@ -0,0 +1,235 @@
|
||||
<!-- docmeta
|
||||
purpose: explanation
|
||||
audience: developer
|
||||
status: historical
|
||||
summary: Plan for one durable identity for the device a PC drives. Phase 1 is built here; phases 2 and 3 are not started.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# Proposal: one identity for the device a PC drives, ending in the registry
|
||||
|
||||
Status: PHASE 1 BUILT AND IN THIS TREE - `plugins/computers/plugin.py` declares
|
||||
`deviceid` on the computers collector schema and prefers it over the older
|
||||
per-family fallbacks. Phases 2 and 3 not started.
|
||||
Author: planning session 2026-08-20.
|
||||
|
||||
## 1. What this is
|
||||
|
||||
A shop-floor PC often drives something: a CMM, a Keyence, a Genspect, a
|
||||
wax-trace unit, a part marker. ShopDB needs to know WHICH one, and the answer
|
||||
has to survive the PC being replaced.
|
||||
|
||||
Today that answer is assembled from whatever each bay family happens to have,
|
||||
and two of the three sources are derived from the PC itself, so they do not
|
||||
survive a swap. This proposal converges them on one identity, moves it from
|
||||
files to the registry, and does it in three phases so no bay is stranded.
|
||||
|
||||
## 2. Why PC-derived identity fails
|
||||
|
||||
Two mechanisms stand in for a real identity today:
|
||||
|
||||
- **Reuse**: look for a prior collector link from THIS PC asset.
|
||||
- **Mint**: build `<PC assetnumber>-<SUFFIX>`, and a PC's asset number is its
|
||||
hostname.
|
||||
|
||||
Both survive a re-image. Neither survives a PC SWAP. A new hostname is a new PC
|
||||
asset with no prior link and a predicted number that has never existed, so the
|
||||
same physical device gets a SECOND record while the first keeps its config and
|
||||
its backup history under a dead PC's name.
|
||||
|
||||
That is not hypothetical: minting from the hostname left 43 legacy `MT-####`
|
||||
tools shadowed by minted twins, three records deep in places.
|
||||
|
||||
## 3. The rail that must not be crossed
|
||||
|
||||
`machinenumber` answers "WHICH BAY is this" and is what GE-Enforce
|
||||
`TargetMachineNumbers` gates on. The device identity answers "WHICH DEVICE is
|
||||
this". They are different facts and must stay in different fields.
|
||||
|
||||
Putting a device id into the machine-number channel would silently stop every
|
||||
bay-gated manifest entry from matching - a failure that looks like nothing
|
||||
happening, on every cycle, with no error anywhere.
|
||||
|
||||
## 4. Where identity comes from today
|
||||
|
||||
| Bay family | Identity source | Survives a PC swap |
|
||||
|---|---|---|
|
||||
| DNC / collections | `MachineNo` in the eDNC/NTLARS registry | yes |
|
||||
| CMM | `C:\Enrollment\cmm\cmmid.txt` | yes, but see below |
|
||||
| Keyence, Genspect, wax-trace | nothing, before phase 1 | no |
|
||||
| Part markers | nothing, before phase 1 | no |
|
||||
|
||||
**`cmmid.txt` is not only an identity file.** It is a PATH KEY, and that is why
|
||||
it cannot simply be retired:
|
||||
|
||||
| Consumer | Use |
|
||||
|---|---|
|
||||
| `resolve-cmm-bay-config.ps1` | writes it |
|
||||
| `Verify-And-Heal-Staging.ps1` | stages `installers-post\cmm\backups\<cmmid>` |
|
||||
| `09-Setup-CMM.ps1` | locates the bay's staged backup set |
|
||||
| `Restore-CMM.ps1` | restores from `<BackupRoot>\<cmmid>\`, same-bay by design |
|
||||
| collector | reads it as `machineNo` |
|
||||
|
||||
Only the last row is an identity use. The rest address directories by it.
|
||||
|
||||
It is also doing the job badly: for a CMM bay it reports the INSTRUMENT's id in
|
||||
the `machinenumber` channel, and the server then resolves it back to a measuring
|
||||
tool at step 4 of its own fallback chain. That round trip is what phases 2 and 3
|
||||
remove.
|
||||
|
||||
## Phase 1 - one field, one file (DONE)
|
||||
|
||||
The collector accepts a single `deviceid`, read from `C:\Enrollment\asset-id.txt`
|
||||
and resolved BEFORE anything PC-derived, for every device family.
|
||||
|
||||
- One file for every bay with no `MachineNo` registry to identify it.
|
||||
- It names the asset and NOT its type: the pc-type already says what kind of
|
||||
device it is, through `SUBORDINATE_DEVICE_MAP`, so a device type declared
|
||||
later through `subordinatedevice_<pctype>` needs no new file and no client
|
||||
change.
|
||||
- Wire field is `deviceid`, not `assetid`, because `assetid` is already this
|
||||
contract's RESPONSE field for a PC's integer primary key.
|
||||
- `measuringtoolid` (0.12.0) is accepted as an alias and the older file is still
|
||||
read, so a bay staged in that window keeps reporting.
|
||||
- Guards: an unresolvable value warns rather than minting a phantom, and a value
|
||||
resolving to the WRONG KIND of asset is refused with the asset named.
|
||||
|
||||
Nothing was removed. Every previous fallback still runs, one place lower.
|
||||
|
||||
## Phase 2 - imaging writes it, and `cmmid.txt` stops being an identity
|
||||
|
||||
Repository: the PXE/imaging repo, not this one. **Needs deploying to the live
|
||||
server, not only committing** - the live boot image is authoritative.
|
||||
|
||||
1. `resolve-cmm-bay-config.ps1` writes `C:\Enrollment\asset-id.txt` ALONGSIDE
|
||||
`cmmid.txt`. Two files, two questions: the identity, and the backup-set key.
|
||||
`cmmid.txt` keeps every path use it has today.
|
||||
2. Every other device pc-type writes `asset-id.txt` at imaging from its bay
|
||||
config. Bays with no device write nothing, and the file's absence stays
|
||||
meaningful.
|
||||
3. The collector demotes `cmmid.txt` to last place in the machine-number chain
|
||||
and stops being the intended path for CMM identity. It is still read, so a
|
||||
grandfathered bay that never gets re-imaged keeps reporting.
|
||||
4. The value written is the device's `assetnumber` as ShopDB holds it. Where a
|
||||
CMM's asset number is already its bay id (`CMM3`), phase 2 is a no-op in
|
||||
content and a change only in which file carries it.
|
||||
|
||||
Exit criterion: a report from a re-imaged bay of each device family carries
|
||||
`deviceid`, and no bay of those families relies on the `cmmid.txt` fallback.
|
||||
The collector response warnings are the check: an adopted device is silent, a
|
||||
minted one is not.
|
||||
|
||||
## Phase 3 - identity moves to the registry
|
||||
|
||||
`HKLM:\SOFTWARE\GE\ShopDB\DeviceId`.
|
||||
|
||||
This is the destination because the registry survives the profile cleanup and
|
||||
disk hygiene that eats `C:\Enrollment`, is readable as SYSTEM with no file ACL
|
||||
to get wrong, and is already how a DNC bay is identified. It needs no new key
|
||||
and no new read path: the collector already opens
|
||||
`HKLM:\SOFTWARE\GE\ShopDB` (and the `WOW6432Node` variant) for `BaseUrl` and
|
||||
`ApiToken`, and `Install-GEEnforce.ps1` already writes that key.
|
||||
|
||||
**One value, every family.** `DeviceId` is a single registry value, not a key
|
||||
per device type. The client sends one field and the pc-type decides which sync
|
||||
consumes it, exactly as `asset-id.txt` works in phase 1, so:
|
||||
|
||||
| Bay | `DeviceId` holds | Replaces |
|
||||
|---|---|---|
|
||||
| CMM | the instrument's `assetnumber` | `cmmid.txt` as IDENTITY only |
|
||||
| Keyence | the instrument's `assetnumber` | nothing; had none |
|
||||
| Genspect | the instrument's `assetnumber` | nothing; had none |
|
||||
| wax-trace | the instrument's `assetnumber` | nothing; had none |
|
||||
| part marker | the marker's `assetnumber` | minting from the hostname |
|
||||
|
||||
A device family added later through `subordinatedevice_<pctype>` is covered on
|
||||
the day it is declared, with no client change and no new registry value.
|
||||
|
||||
Resolution order after phase 3:
|
||||
|
||||
1. `HKLM:\SOFTWARE\GE\ShopDB\DeviceId`
|
||||
2. `C:\Enrollment\asset-id.txt`
|
||||
3. `C:\Enrollment\measuringtool-id.txt` (0.12.0 window)
|
||||
4. `C:\Enrollment\cmm\cmmid.txt` as `machineNo` (grandfathered CMM bays)
|
||||
|
||||
Then, and only then, the file branches can be deleted one at a time, newest
|
||||
first, each when no bay has reported through it for a full inventory cycle.
|
||||
|
||||
**`cmmid.txt` survives phase 3 regardless**, as the CMM backup-set path key. It
|
||||
is not on the deletion list; only its identity use is.
|
||||
|
||||
### PXE and GE-Enforce prefer it too, not just the collector
|
||||
|
||||
Phase 3 is not only "the collector reads a registry value". All three writers
|
||||
and readers move together:
|
||||
|
||||
| Component | Change |
|
||||
|---|---|
|
||||
| PXE / imaging | writes `DeviceId` at enrollment instead of (or as well as) the file |
|
||||
| GE-Enforce | resolves device identity registry-first, mirroring how it already resolves the machine number |
|
||||
| collector | reads the registry ahead of the file |
|
||||
|
||||
**GE-Enforce already made this exact decision for the machine number, and wrote
|
||||
down why.** `Install-FromManifest.ps1` resolves `TargetMachineNumbers` gating
|
||||
from the eDNC registry BEFORE `C:\Enrollment\machine-number.txt`, because:
|
||||
|
||||
> the imaging-time `machine-number.txt` is written ONCE by `startnet.cmd` at the
|
||||
> PXE menu and is NOT updated on reassignment, so it goes stale.
|
||||
|
||||
**`asset-id.txt` inherits that defect exactly.** It is written once at imaging
|
||||
and nothing updates it when a device is swapped, moved between bays, or
|
||||
replaced. The bay then reports an identity that was true on imaging day, and the
|
||||
collector - which now trusts that value FIRST, ahead of every other source -
|
||||
adopts the wrong device with no warning, because the value still resolves.
|
||||
|
||||
That makes phase 3 a correctness fix rather than a tidy-up. A file that only
|
||||
imaging writes cannot be the top of a resolution order for something that
|
||||
changes during a PC's life.
|
||||
|
||||
It also implies a reassignment path, the way `Set-MachineNumber` /
|
||||
`Update-MachineNumber` exist for the bay number: something that rewrites
|
||||
`DeviceId` when a device is swapped, and migrates any per-device state with it.
|
||||
Without that, phase 3 moves the staleness from a file to a registry value rather
|
||||
than curing it.
|
||||
|
||||
### Doing phase 3 early is cheaper than doing it late
|
||||
|
||||
If the registry read lands before imaging is changed, phase 2 can write the
|
||||
REGISTRY for new bays and skip the file era entirely for them. The file then
|
||||
exists only for bays staged in the window between phase 1 and phase 2, and the
|
||||
eventual cleanup is deleting a code branch rather than sweeping the fleet.
|
||||
|
||||
The cost of adding the read now is one branch in the client and one row in the
|
||||
docs. The cost of adding it after every bay has a file is a migration.
|
||||
|
||||
## 5. What this does not change
|
||||
|
||||
- `machinenumber` and its GE-Enforce gating. Untouched in all three phases.
|
||||
- A part marker still files `partof` the operation from `machinenumber`. The
|
||||
device identity says WHICH marker the PC controls, not which operation that
|
||||
marker belongs to.
|
||||
- Minting. It stays as the last resort for a bay that names nothing, because a
|
||||
device nobody declared is still better recorded than not recorded. Anything
|
||||
minted should be treated as a placeholder to be reconciled.
|
||||
|
||||
## 6. Before phase 2: find what is already duplicated
|
||||
|
||||
Phase 1 stops NEW duplicates. It does not merge the ones a PC swap has already
|
||||
created. Before imaging starts writing files, run a read-only query for device
|
||||
assets whose controlling PC no longer exists, or whose asset number carries a
|
||||
hostname that is not a current PC. That list is the reconciliation backlog, and
|
||||
it is much easier to read before the fleet starts reporting new identities over
|
||||
the top of it.
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
Each phase is independently reversible because nothing is removed until the
|
||||
phase after it proves the replacement:
|
||||
|
||||
- Phase 1: stop sending `deviceid` and every previous fallback still runs.
|
||||
- Phase 2: stop writing `asset-id.txt` and bays fall back to what they used
|
||||
before.
|
||||
- Phase 3: stop writing the registry value and bays fall back to the file.
|
||||
|
||||
The only irreversible step is deleting a fallback branch, which is deliberately
|
||||
outside all three phases.
|
||||
656
docs/proposals/ge-enforce-plugin.md
Normal file
656
docs/proposals/ge-enforce-plugin.md
Normal file
@@ -0,0 +1,656 @@
|
||||
<!-- docmeta
|
||||
purpose: explanation
|
||||
audience: developer
|
||||
status: historical
|
||||
summary: The 2026-07 design for GE-Enforce manifests as ShopDB data. Adopted by ADR-012 and built.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# Proposal: GE-Enforce as a shopdb plugin
|
||||
|
||||
Status: ACCEPTED / built - see ADR-012 and plugins/geenforce/.
|
||||
Author: planning session 2026-07-12.
|
||||
|
||||
## 1. What this is
|
||||
|
||||
Today GE-Enforce is a PowerShell manifest engine that reads per-PC-type
|
||||
`manifest.json` files off an SMB share (`\\shopdb.example.net\
|
||||
shared\dt\shopfloor\`). Each logon, a scheduled task running as SYSTEM mounts
|
||||
the share, reads the manifest for the machine's PC type, and installs or
|
||||
self-heals apps, files, drivers, registry values, and scripts. A parallel
|
||||
`preinstall.json` runs the same schema once at imaging.
|
||||
|
||||
This proposal turns the *manifest* into shopdb data: the authoritative manifest
|
||||
lives in the shopdb database, is edited through the shopdb UI (an expansion of
|
||||
`/settings/pctypemapping`), and is served to clients over HTTP as JSON. The
|
||||
*payloads* (MSI/EXE/PS1/config bytes) stay on SMB, on HTTP, or both, referenced
|
||||
by URL/path from the manifest rows. GE-Enforce.ps1 changes from "read a file on
|
||||
W:" to "GET a manifest from shopdb, then fetch each payload from wherever the
|
||||
row says."
|
||||
|
||||
The result: managing imaging PC types, their apps, scripts, files, registry
|
||||
rules, and version gates becomes a first-class shopdb feature instead of hand-
|
||||
edited JSON on a file share.
|
||||
|
||||
## 2. Why it fits shopdb
|
||||
|
||||
- shopdb already models the fleet (the collector ingests every PC's hostname,
|
||||
pctype, installed software, versions). Making shopdb *also* own what SHOULD be
|
||||
installed closes the loop: desired-state (manifest) and observed-state
|
||||
(collector) live in one system and can be diffed.
|
||||
- `/settings/pctypemapping` already maps `gea-shopfloor-*` PC types to
|
||||
`ComputerType`. That page becomes the entry point for full imaging-PC-type
|
||||
management.
|
||||
- The plugin contract (per-plugin models, migrations, API prefix, settings
|
||||
cards, collector hooks) is exactly the shape this needs.
|
||||
- ADR-004 (per-site instances) matches: each site's shopdb owns each site's
|
||||
manifest. No multi-tenant complication.
|
||||
|
||||
## 3. Grounding: the real manifest schema
|
||||
|
||||
Source of truth for these field names (do not invent others):
|
||||
|
||||
- Schema: `<imaging-share>/SHOPDBHOST-v2/shared/dt/shopfloor/_meta/manifest-schema.json`
|
||||
- Engine: `<imaging-share>/common/lib/Install-FromManifest.ps1`
|
||||
- Dispatcher: `.../shopfloor/common/GE-Enforce.ps1`
|
||||
- Architecture: `pxe/docs/ge-enforce-v2-architecture.md`
|
||||
|
||||
A manifest is `{ "Version": str, "_comment": str, "Applications": [entry, ...] }`.
|
||||
Only `Name` and `Type` are required per entry.
|
||||
|
||||
### Per-entry fields (complete set)
|
||||
|
||||
Identity / action:
|
||||
- `Name` (required, unique, also the status-key `<scope>/<Name>`)
|
||||
- `Type` (required): one of `MSI EXE CMD BAT PS1 INF File Registry`
|
||||
- `_comment` (documentation, heavily used in practice)
|
||||
|
||||
Type-specific payload references (sparse; depends on Type):
|
||||
- MSI/EXE/CMD/BAT/INF: `Installer` (relative path) + `InstallArgs`
|
||||
- PS1: `Script` (relative path, falls back to `Installer`) + `Args`
|
||||
- File: `Source` (relative) + `Destination` (absolute on-PC path)
|
||||
- Registry: `RegPath` + `RegName` + `RegValue` + `RegType`
|
||||
(`RegType` in `String DWord QWord MultiString ExpandString Binary`)
|
||||
- Optional `LogFile`, `WaitTimeoutSec` (EXE hang kill), `InUseCheck`
|
||||
|
||||
Detection (decides whether the action fires / self-heals):
|
||||
- `DetectionMethod`: one of
|
||||
`Registry File FileVersion Hash MarkerFile ValueMatches pnputil Always`
|
||||
- `DetectionPath`, `DetectionName`, `DetectionValue`, `DetectionPattern`
|
||||
- Note: `DetectionValue` is method-dependent - SHA256 for Hash, a 4-part
|
||||
version for FileVersion, a registry value for Registry, ignored for
|
||||
Always/File. Same column, different meaning per method.
|
||||
- No `DetectionMethod` = always installs.
|
||||
|
||||
Targeting filters (all ANDed; each is multi-value):
|
||||
- `PCTypes` (array; `"*"` = all; alias graph expands old<->new names)
|
||||
- `PCSubTypes` / subtype via `<pctype>-<subtype>` values
|
||||
- `TargetHostnames` (array; exact + `-like WJS-*` wildcards)
|
||||
- `TargetMachineNumbers` (array; per-bay)
|
||||
- `_CmmVersion` (scalar; per-entry PC-DMIS version gate, needs lib >= 2.6)
|
||||
|
||||
Nested:
|
||||
- `InUseCheck`: `{ Behavior, Processes: [{Name, ExePath, GracefulCloseTimeoutSec}] }`
|
||||
Behavior in `Defer CloseAndReopen ForceClose ScheduleForReboot`
|
||||
|
||||
Parsed-but-inert today (model them, mark inert):
|
||||
- `ApplyMode` (`Nightly Immediate ImmediateReboot`), `UpdateWindow` (`HH:MM-HH:MM`)
|
||||
|
||||
Preinstall-only extras (phase discriminator):
|
||||
- `PreEnrollment`, `KillAfterDetection`, `PCTypesStrict`, `_pcTypesNote`
|
||||
|
||||
### Load-bearing behaviors the model must preserve
|
||||
|
||||
1. **Array order IS execution order.** Config-restore entries are deliberately
|
||||
placed AFTER their vendor installer so a mid-cycle overwrite heals the same
|
||||
cycle (eMxInfo.txt after eDNC; udc_webserver_settings after UDC). We MUST
|
||||
store an explicit per-scope `sortorder`, not a set.
|
||||
2. **PCTypes alias graph** is many-to-many old<->new names resolved by set
|
||||
intersection, with a `PCTypesStrict` escape hatch. Not a simple FK.
|
||||
3. **Polymorphic entry by Type** - sparse column set per type. DECISION: one
|
||||
wide `manifestentries` table with an `entrytype` discriminator column and
|
||||
nullable per-type columns. NOT SQLAlchemy STI subclasses, NOT a JSON blob.
|
||||
Justification (section 4): the whole fleet is ~64 entries, so sparse columns
|
||||
cost nothing; real columns get validated, indexed, field-diffed, joined
|
||||
against collector data, and read in plain SQL by an IT tech - a JSON blob
|
||||
hides all of that, and class-per-type STI is expert ceremony for no gain. A
|
||||
`validate()` that switches on `entrytype` (mirroring the engine's own
|
||||
`switch ($App.Type)`) is ~40 obvious lines.
|
||||
4. **Two manifest phases** - runtime (self-heal, per logon) and preinstall
|
||||
(once at imaging) share the schema. One table with a `phase` discriminator.
|
||||
|
||||
## 4. Data model (new `geenforce` plugin)
|
||||
|
||||
Per-plugin Alembic chain (ADR-008). Tables (lowercase concatenated per naming
|
||||
convention). Sizing that shapes every decision here: the real fleet is 10
|
||||
runtime scopes = 43 entries, plus 1 preinstall manifest = 21 entries, so ~64
|
||||
rows total. That smallness is why this stays deliberately low-tech (one wide
|
||||
table, JSON-document snapshots, no row-mirroring) - the design target is an
|
||||
average site IT tech maintaining it, not a specialist.
|
||||
|
||||
- `manifestscopes` - one row per imaging PC type / scope.
|
||||
- `scopeid` PK
|
||||
- `scopename` (e.g. `gea-shopfloor-cmm`)
|
||||
- `phase` enum (`runtime` | `preinstall`)
|
||||
- UNIQUE (`scopename`, `phase`), NOT `scopename` alone: `common` exists in
|
||||
runtime, and a scope name can appear in both phases. Note the phases are
|
||||
shaped differently - runtime is many per-pctype scopes (one manifest file
|
||||
each), preinstall is ONE flat manifest gated internally by `PCTypes`, so
|
||||
preinstall is modeled as a single `phase=preinstall` scope, not per-pctype
|
||||
scopes.
|
||||
- `computertypeid` FK -> `computertypes` (this REPLACES the thin
|
||||
`pctypemap_<pxetype>` setting; the mapping becomes a column here).
|
||||
Runtime-scope only; null for the preinstall scope.
|
||||
- `measuringtooltypeid` FK -> `measuringtooltypes`, nullable (metrology
|
||||
scopes: what device this scope implies; keeps imaging + collector agreed,
|
||||
see section 11).
|
||||
- `manifestversion` (string, mirrors manifest `Version`)
|
||||
- `description`, `isactive`
|
||||
- `iscommon` bool (the `common/` fleet-wide scope)
|
||||
|
||||
- `manifestentries` - one row per Applications[] entry (the working/draft copy).
|
||||
- `entryid` PK, `scopeid` FK
|
||||
- `sortorder` int (preserves array order; the ordering contract)
|
||||
- `name`, `entrytype` (MSI/EXE/.../Registry), `comment`
|
||||
- payload columns (nullable, per type): `installer`, `installargs`,
|
||||
`scriptpath`, `scriptargs`, `sourcepath`, `destination`,
|
||||
`regpath`, `regname`, `regvalue`, `regtype`
|
||||
- `payloadsource` enum (`smb` | `http` | `inline`) + `payloadref`
|
||||
(see section 5)
|
||||
- `payloadsha256` - integrity hash of the payload bytes, INDEPENDENT of the
|
||||
detection method. Mandatory for `http`/`inline` payloads; optional for
|
||||
`smb`. Do NOT reuse `detectionvalue` for this - `detectionvalue` is a
|
||||
SHA256 only when `detectionmethod = Hash`; an MSI with `Registry`/
|
||||
`FileVersion` detection has no payload hash, so an HTTP fetch would
|
||||
otherwise run unverified bytes (see section 5).
|
||||
- `regvalue` stores the RAW JSON literal (`1` vs `"1"`) and is emitted
|
||||
verbatim on export. `RegValue` is untyped in the manifest schema and real
|
||||
entries carry numbers; the engine string-coerces for `ValueMatches` but
|
||||
`Set-ItemProperty -Type DWord` cares, so preserve the literal.
|
||||
- detection columns: `detectionmethod`, `detectionpath`, `detectionname`,
|
||||
`detectionvalue`, `detectionpattern`
|
||||
- gates: `cmmversion`, plus child tables for the multi-value filters
|
||||
- control: `logfile`, `waittimeoutsec`, `applymode`, `updatewindow`
|
||||
(`applymode`/`updatewindow` are parsed-but-INERT in the engine today; the
|
||||
UI must label them "not yet enforced" so a tech does not trust a dead gate)
|
||||
- preinstall flags: `preenrollment`, `killafterdetection`, `pctypesstrict`
|
||||
- `isactive`
|
||||
|
||||
- `manifestpublishedversions` - immutable published snapshots, SIMPLIFIED to
|
||||
freeze the rendered JSON DOCUMENT in a single `manifestjson` column (drop the
|
||||
row-mirrored `manifestpublishedentries` family the earlier draft proposed).
|
||||
The only consumer of a snapshot is the client, and it consumes exactly that
|
||||
document, so freezing the text makes immutability structural (no UPDATE path),
|
||||
rollback a one-flag `iscurrent` flip, serving a single-row read, and version
|
||||
diffing a plain text diff - all things average IT can debug; row-mirroring
|
||||
would add ~6 shadow tables and a copy routine that can drift. Columns:
|
||||
`publishedversionid`, `scopeid`, `versionnumber` (1,2,3 per scope),
|
||||
`manifestjson` (MEDIUMTEXT, verbatim), `publishedat`, `publishedby`,
|
||||
`iscurrent`, `notes`. Editing `manifestentries` never affects the fleet;
|
||||
"publish" freezes a new snapshot; the client is ALWAYS served the current
|
||||
snapshot, never the live draft. Rollback = flip `iscurrent` to an older
|
||||
version (the post-cutover safety net once the on-share JSON is retired).
|
||||
Mirrors today's `_meta/history/<date>-<scope>.json` backups, but authoritative.
|
||||
Revision history: every publish is a permanent, immutable revision kept
|
||||
indefinitely (snapshots are small JSON text, ~10 scopes - storage is a
|
||||
non-issue). An OPTIONAL retention policy (keep last M per scope, or prune
|
||||
older than N months) can be added later; default is keep-everything, off.
|
||||
|
||||
- Draft-edit audit trail (field-level history BETWEEN publishes): drafts
|
||||
(`manifestentries`) are not versioned - editing overwrites the working copy.
|
||||
To answer "who changed this entry and when" in the window between two
|
||||
published revisions, log every draft mutation through the EXISTING core audit
|
||||
system (no new table): on create/update/delete of a scope, entry, or child
|
||||
row, write an audit record with the actor, timestamp, entry name, and the
|
||||
changed field(s). This gives per-edit provenance for free and shows up in the
|
||||
same Audit Logs UI IT already uses; the published snapshots remain the
|
||||
coarse-grained "what the fleet actually got" record.
|
||||
|
||||
- `manifestentrypctypes`, `manifestentryhostnames`, `manifestentrymachinenumbers`
|
||||
- child rows for the ANDed multi-value filters (one value + a `sortorder` per
|
||||
row, wildcards stored verbatim as patterns)
|
||||
|
||||
- `manifestinusechecks` + `manifestinusecheckprocesses`
|
||||
- the nested InUseCheck object and its Processes[] child list (leave
|
||||
`gracefulclosetimeoutsec` nullable; do not bake the engine's default of 10
|
||||
into the row, emit it only when set)
|
||||
|
||||
- `manifestpayloads` - inline payload bytes for `payloadsource = inline`
|
||||
(`entryid`, `filename`, `contenttype`, `payloadbytes` LONGBLOB, `payloadsha256`,
|
||||
`uploadedat`). App-enforced size cap ~1 MB; the upload UI rejects larger with
|
||||
"use SMB for this" so nobody pastes an MSI into the database. Can ship empty
|
||||
and unused until P6.
|
||||
|
||||
- `pctypealiases` - a MIRROR of the old<->new name alias graph from
|
||||
`Install-FromManifest.ps1:463-475`, for server-side resolve/validate only.
|
||||
The engine lib stays the single source of truth (see section 10); shopdb
|
||||
never becomes the authority the client depends on for aliases.
|
||||
|
||||
The JSON the client receives is REBUILT from a published snapshot in exact
|
||||
array order. Parity with the current engine is proven by BEHAVIORAL equivalence,
|
||||
not byte-identity (see section 9): re-serialized JSON will differ in key order
|
||||
and whitespace, so the test is that both manifests parse to the same ordered
|
||||
entry set with the same detection/targeting/action semantics.
|
||||
|
||||
## 5. Payloads: SMB and/or HTTP (both supported)
|
||||
|
||||
The user asked whether payloads can be SMB and/or HTTP. Yes - per entry:
|
||||
|
||||
- `payloadsource = smb`: `payloadref` is the current relative path
|
||||
(`apps/eDNC_6-4-5.msi`); the client still mounts W: and resolves it against
|
||||
the scope root exactly as today. The engine is unchanged for these rows (the
|
||||
mount + scope-root resolution still happen; an HTTP-only site skips the mount
|
||||
because it has no `smb` rows). This is the default and the migration target
|
||||
for large binaries (MSIs are hundreds of MB; SMB streaming beats HTTP).
|
||||
- `payloadsource = http`: `payloadref` is a URL (absolute, or relative to a
|
||||
configured payload base). The client downloads to a local temp dir, verifies
|
||||
the Hash/FileVersion detection value, then runs it. Good for small
|
||||
config/script payloads and for sites with no SMB share.
|
||||
- `payloadsource = inline`: for small text payloads (a `.ps1`, a config file, a
|
||||
registry value), the bytes live in shopdb itself and are served in-band. No
|
||||
external store at all. Best for scripts and File-type config drops.
|
||||
|
||||
Manifest generation emits, per entry, whatever the client needs to fetch the
|
||||
bytes. The engine's existing "stage network EXE to local temp first" logic
|
||||
(SYSTEM access-denied workaround) generalizes cleanly to HTTP download.
|
||||
|
||||
Payload integrity uses the dedicated `payloadsha256` column, NOT `DetectionValue`.
|
||||
This is the correction to a subtle trap: `DetectionValue` is a SHA256 only when
|
||||
`DetectionMethod = Hash`. Most binaries detect by `Registry` or `FileVersion`
|
||||
and carry no payload hash at all, so relying on `DetectionValue` would let an
|
||||
HTTP/inline-fetched MSI run unverified. Instead, publishing an `http`/`inline`
|
||||
payload computes and stores `payloadsha256`, and the client verifies the fetched
|
||||
bytes against it BEFORE running, independent of how the entry detects install
|
||||
state. `smb` payloads may set it too (defense in depth) but the share ACL is
|
||||
their primary trust boundary. Detection stays a separate concern: it decides
|
||||
whether to act; the payload hash decides whether the bytes are trustworthy.
|
||||
|
||||
Transport security: the client fetches as SYSTEM, so the shopdb TLS cert must be
|
||||
trusted machine-wide. Sites with a self-signed or air-gapped shopdb need the CA
|
||||
in the machine trust store (provisioned by the same Azure DSC step that writes
|
||||
the token). Plain HTTP is acceptable only inside a trusted segment, and even
|
||||
then the `payloadsha256` check is what actually guarantees payload integrity.
|
||||
|
||||
## 6. API surface (`/api/geenforce/...`)
|
||||
|
||||
Two permissions via the plugin's `get_permissions()` hook (split so day-to-day
|
||||
techs can edit but only a lead ships to the fleet):
|
||||
- `geenforce.manage` - create/edit/reorder scopes, entries, drafts, payloads.
|
||||
- `geenforce.publish` - publish, rollback, export-to-share (the fleet-affecting
|
||||
actions).
|
||||
|
||||
Draft editing (`geenforce.manage`):
|
||||
- `GET/POST /scopes`, `GET/PUT/DELETE /scopes/<id>` - imaging PC types
|
||||
- `GET/POST /scopes/<id>/entries`, `PUT/DELETE /entries/<id>` - manifest entries
|
||||
- `PUT /scopes/<id>/entries/reorder` - the ordering contract; Move Up/Down in the
|
||||
UI (plain buttons + visible `sortorder`), not a drag-and-drop dependency
|
||||
- `POST /entries/<id>/payload` - upload an inline/http payload (multipart),
|
||||
compute + store its `payloadsha256` (the integrity hash; NOT `detectionvalue`)
|
||||
- `GET /scopes/<id>/preview` - the draft JSON a client WOULD receive on next
|
||||
publish; `GET /scopes/<id>/published` shows the currently-served snapshot
|
||||
- `GET /scopes/<id>/simulate?pctype=&subtype=&hostname=&machinenumber=&cmmversion=`
|
||||
- the "what would this PC get" simulator: runs the entry list through the same
|
||||
filter logic the engine uses and returns which entries apply and why the rest
|
||||
are filtered out. Reuses the P1 parity harness's filter engine, so it is
|
||||
nearly free, and it is the single most IT-empowering endpoint - it answers
|
||||
"why did/didn't app X install on PC Y" without reading a PowerShell log.
|
||||
|
||||
Publishing (`geenforce.publish`):
|
||||
- `POST /scopes/<id>/publish` - freeze the current draft into a new immutable
|
||||
`manifestpublishedversions` snapshot (this is what the fleet gets)
|
||||
- `POST /scopes/<id>/rollback/<version>` - mark an older snapshot current
|
||||
- `POST /scopes/<id>/export-share` (or a `flask geenforce export-share` CLI) -
|
||||
write the current published JSON to `<shareroot>/<scope>/manifest.json` after
|
||||
copying the existing file to `_meta/history/<date>-<scope>.json`. This is a
|
||||
first-class feature, not a footnote: it is the Milestone 1 product (author in
|
||||
shopdb, engine untouched) and the permanent break-glass path.
|
||||
|
||||
Client-facing (gated by a collector-style service token, `geenforce.fetch`
|
||||
scope, reusing the PAT + `X-API-Key` machinery already built for the collector):
|
||||
- `GET /manifest?pctype=<scope>&subtype=<s>&hostname=<h>&machinenumber=<n>`
|
||||
Returns the latest PUBLISHED snapshot for that scope (never the live draft).
|
||||
The server can pre-apply the PCTypes/hostname/machinenumber/cmmversion filters
|
||||
(thin client) OR return the full scope and let the engine filter (fat client,
|
||||
matches today). Start fat: return the scope manifest unchanged so the engine
|
||||
logic is untouched. Include the snapshot version + an ETag so the client can
|
||||
cache and no-op when unchanged.
|
||||
- Payload fetch for `http`/`inline` rows: `GET /payload/<entryid>` streaming the
|
||||
bytes; the client verifies them against `payloadsha256` from the manifest.
|
||||
|
||||
## 7. Frontend: expand `/settings/pctypemapping`
|
||||
|
||||
The current page (`PCTypeMappingSettings.vue`, "Collector PC Types") is a read-
|
||||
only-ish table of `pxetype -> ComputerType` dropdowns. It grows into the imaging-
|
||||
PC-type manager:
|
||||
|
||||
- **Scopes list**: add/rename/delete imaging PC types; each still carries its
|
||||
`ComputerType` mapping (that column moves from a setting into `manifestscopes`).
|
||||
A `phase` toggle (runtime vs preinstall). Common scope flagged.
|
||||
- **Scope detail / manifest editor**: an ordered list of entries with Move
|
||||
Up/Down buttons and a visible `sortorder` (the ordering contract made visible;
|
||||
NOT drag-and-drop - a drag library is the kind of dependency that breaks
|
||||
silently and average IT cannot fix; add drag later if wanted). Each entry is a
|
||||
typed form - the visible fields switch on `entrytype` (MSI shows
|
||||
Installer+InstallArgs; PS1 shows Script+Args; File shows Source+Destination;
|
||||
Registry shows the Reg* quartet), one line of help per detection method.
|
||||
Filter chips for PCTypes/hostnames/machine numbers. InUseCheck sub-editor.
|
||||
Payload source selector (smb/http/inline) with upload for the latter two.
|
||||
`applymode`/`updatewindow` sit behind an "Advanced (not yet enforced by the
|
||||
engine)" disclosure. Ship the editor in three usable-alone increments: (a)
|
||||
scope list + entry table, (b) the typed entry form, (c) publish + diff. That
|
||||
keeps the biggest chunk of the build from ballooning.
|
||||
- **Simulator ("what would this PC get")**: a small form (pctype, subtype,
|
||||
hostname, machine number, CMM version) that calls `GET /scopes/<id>/simulate`
|
||||
and lists which entries apply and why the rest are filtered. The single most
|
||||
IT-empowering piece of the UI.
|
||||
- **Draft, preview, publish**: editing changes only the draft; "publish" freezes
|
||||
an immutable snapshot (see section 4) and is what the fleet then gets. Show the
|
||||
draft-vs-published diff before publishing. Rollback republishes a prior
|
||||
snapshot.
|
||||
- **Desired vs observed (BUILT: observed-state reporting)**: rather than extend
|
||||
the collector, the plugin has its own reporting path. Each enforcement cycle a
|
||||
PC POSTs `POST /api/geenforce/report` (geenforce.report service token) with the
|
||||
published version it applied, the installed/skipped/failed/filtered counts, and
|
||||
per-entry outcomes. Stored in `manifestenforcementreports` (latest-per-host +
|
||||
history) and `manifestenforcementresults` (per-entry). Two payoffs fall out:
|
||||
RECEIVED - `receivedlatest` compares the applied version to the scope's current
|
||||
published version, so the fleet view shows which PCs picked up an update; and
|
||||
SELF-HEAL - each entry's action (installed = drift corrected, skipped = already
|
||||
good, failed) with any warning/error message. Admin reads: `GET /reports`
|
||||
(fleet compliance) and `GET /reports/<id>` (per-entry detail). This is the
|
||||
observed half that makes the manifest a closed desired-vs-observed loop.
|
||||
|
||||
This is an ADR-010 settings card contributed by the geenforce plugin, so it only
|
||||
appears when the plugin is enabled.
|
||||
|
||||
## 8. Client change (minimal, staged)
|
||||
|
||||
`GE-Enforce.ps1` today: mount W:, read `<scope>\manifest.json`, hand to
|
||||
`Install-FromManifest`. New path: GET the manifest from shopdb, write it to the
|
||||
same local location the engine reads, then run the engine unchanged. That is the
|
||||
smallest possible client delta - the engine, detection logic, self-heal, and
|
||||
SMB payload resolution all stay identical. Only the *source of the JSON* moves
|
||||
from file to HTTP.
|
||||
|
||||
Payloads: `smb` rows need no client change. `http`/`inline` rows need a small
|
||||
fetch-and-verify helper (download to temp, check SHA256, then the existing
|
||||
installer action runs against the local copy). The engine already stages network
|
||||
EXEs to temp, so this is an extension, not a rewrite.
|
||||
|
||||
Auth: the client already has SFLD credentials in
|
||||
`HKLM:\SOFTWARE\GE\SFLD\Credentials`. Add a shopdb service token (a
|
||||
`geenforce.fetch` PAT) provisioned the same way (Azure DSC writes it to
|
||||
registry), sent as `X-API-Key`. If shopdb is unreachable, the client falls back
|
||||
to the last-known-good manifest cached locally (fail-safe: never leave a PC
|
||||
unmanaged because the web app is down). This mirrors today's "creds missing =
|
||||
exit 0, retry next cycle" resilience.
|
||||
|
||||
## 9. Cutover strategy
|
||||
|
||||
The manifest is desired-state that runs as SYSTEM and installs software fleet-
|
||||
wide. A bad cutover = a fleet-wide mis-install. Stage it:
|
||||
|
||||
1. **Import + parity.** Write a one-shot importer that reads the current
|
||||
on-share manifests (common + every `gea-shopfloor-*` + preinstall.json;
|
||||
skip `.bak` / `.pre-mtconnect.bak` variants) into the new tables. Then
|
||||
generate JSON back out and prove BEHAVIORAL equivalence for every scope - do
|
||||
NOT chase byte-identity. Re-serialized JSON will differ in key order,
|
||||
whitespace, and `_comment` formatting, so a raw `diff` would never converge.
|
||||
The correct test: parse both the original and the regenerated manifest,
|
||||
normalize, and assert the same ordered entry list with identical
|
||||
detection/targeting/action fields per entry (ideally a small harness that
|
||||
mimics the engine's filter+detect decisions and confirms the same entries
|
||||
would fire in the same order on representative machine profiles). That, not
|
||||
byte equality, is what proves the model is lossless. (Same discipline as the
|
||||
ADR-001 data migration.)
|
||||
2. **Shadow mode.** shopdb serves the manifest at a new endpoint; a canary PC
|
||||
fetches from shopdb but ALSO reads the share, and logs any diff. No install
|
||||
behavior changes. Run across one of each PC type for a few cycles.
|
||||
3. **Read cutover, payloads still SMB.** Flip GE-Enforce to source the JSON from
|
||||
shopdb (payloads stay `smb`). The blast radius is only "where the JSON comes
|
||||
from"; the bytes and engine are unchanged. Keep the share manifests as the
|
||||
rollback (revert the dispatcher one-liner).
|
||||
4. **Payload migration (optional, per entry).** Move small scripts/configs to
|
||||
`inline`/`http` opportunistically. Leave big MSIs on SMB indefinitely - SMB
|
||||
is the right transport for them.
|
||||
5. **Author in shopdb.** Once read-cutover is stable, new manifest edits happen
|
||||
in the shopdb UI and the on-share JSON is retired (or auto-exported as a
|
||||
backup for break-glass).
|
||||
|
||||
Rollback during cutover (stages 2-4) is a one-line dispatcher revert, because
|
||||
the engine and payload layout never stop working from the share. AFTER the share
|
||||
JSON is retired (stage 5), that escape hatch is gone - post-cutover rollback is
|
||||
republishing a prior `manifestpublishedversions` snapshot (section 4). Both
|
||||
mechanisms must exist before stage 5, not just the dispatcher revert.
|
||||
|
||||
## 10. Risks / open questions
|
||||
|
||||
- **The engine is the contract.** Any drift between shopdb's generated JSON and
|
||||
what `Install-FromManifest.ps1` expects is a fleet-wide install bug. The
|
||||
byte-identical round-trip test (step 1) is non-negotiable, and the plugin must
|
||||
pin which engine lib version it targets (>= 2.6 for `_CmmVersion`).
|
||||
- **PCTypes alias graph** must be kept in sync with
|
||||
`Install-FromManifest.ps1:463-475`. The engine lib stays the single source of
|
||||
truth; shopdb only MIRRORS the map for server-side validation. Do NOT invert
|
||||
this to have the engine fetch aliases from shopdb - that would add exactly the
|
||||
availability coupling the next bullet warns against. When the lib's alias map
|
||||
changes, update shopdb's mirror as part of shipping that lib version.
|
||||
- **Availability coupling.** GE-Enforce currently depends only on SMB. Adding an
|
||||
HTTP dependency on shopdb means shopdb downtime could stall enforcement -
|
||||
hence the last-known-good local cache in section 8. Must be built in from day
|
||||
one, not bolted on. This is also why alias resolution and payloads stay
|
||||
independent of a live shopdb wherever possible.
|
||||
- **Transport trust.** The client runs as SYSTEM, so shopdb's TLS cert must be
|
||||
in the machine trust store (self-signed/air-gapped sites need the CA
|
||||
provisioned via the same DSC step as the token). `payloadsha256` verification
|
||||
is the real integrity guarantee and holds even over plain HTTP inside a
|
||||
trusted segment (section 5).
|
||||
- **Secrets in payloads.** Some config drops (site-config, credentials) may
|
||||
contain secrets. `inline` payloads live in the shopdb DB - those must respect
|
||||
the existing "secrets stay in .env, not the settings table" rule. Likely keep
|
||||
any secret-bearing payload on SMB with ACLs, never inline.
|
||||
- **Preinstall runner** is a separate consumer (`00-PreInstall-*` at imaging,
|
||||
before enrollment). It may not have a shopdb token yet at that point in the
|
||||
imaging sequence. Preinstall may need to stay share-sourced longer than
|
||||
runtime, or fetch a bootstrap manifest anonymously over HTTP.
|
||||
- **This is a big build.** Realistically phased: (P1) model + importer +
|
||||
behavioral-parity test; (P2) admin API + CRUD + publish/snapshot/rollback;
|
||||
(P3) frontend editor on /settings/pctypemapping; (P4) client fetch + shadow
|
||||
mode; (P5) read cutover; (P6) payload migration. P1 is the gating de-risk - if
|
||||
behavioral parity does not hold, stop. Snapshots (P2) must land before any
|
||||
client points at shopdb (P4), since serving the live draft to the fleet is
|
||||
unacceptable.
|
||||
|
||||
## 11. Relationship to existing work
|
||||
|
||||
- Replaces `plugins/computers/pctypemap.py` (the thin `pctypemap_<pxetype>`
|
||||
settings) - the pctype -> ComputerType mapping becomes the `computertypeid`
|
||||
column on `manifestscopes`. Two-source transition window: `pctype_mapping()`
|
||||
must keep reading the settings until the geenforce plugin is enabled, then
|
||||
fall back geenforce-table-first / settings-second, and only retire
|
||||
`seed_pctype_settings` + the settings at Milestone 1 close. Also reconcile the
|
||||
scope inventory: `pctypemap.py` lists `gea-shopfloor-display` but the share has
|
||||
no such manifest dir, and the share has a `main/` legacy dir the model ignores
|
||||
- the importer creates scopes only from what it finds (plus empty scopes for
|
||||
mapped-but-absent pctypes), and the P1 gate review reconciles the list with
|
||||
the floor team.
|
||||
- Also folds in the metrology mapping now living in `pctypemap.py`
|
||||
(`METROLOGY_TOOL_MAP`). The collector already auto-creates a MeasuringTool
|
||||
asset and a directional PC->tool `controls` relationship when it sees a
|
||||
metrology pctype (CMM / Keyence / Genspect / wax-and-trace); the PC stays a
|
||||
shopfloor PC. A metrology scope in the manifest model should carry the
|
||||
attached-measuring-tool type alongside its ComputerType so imaging and
|
||||
collector agree on what device the scope implies.
|
||||
- Reuses the collector's token machinery (PAT + `X-API-Key` + scopes) for the
|
||||
client-facing endpoints.
|
||||
- Reuses `get_permissions()` (contract 0.10.0) for `geenforce.manage` (edit
|
||||
drafts) / `geenforce.publish` (publish, rollback, export) / `geenforce.fetch`
|
||||
(the client service token).
|
||||
- Pairs with the collector: desired-state (this plugin) + observed-state
|
||||
(collector) enable a fleet compliance view.
|
||||
|
||||
## 12. Recommendation
|
||||
|
||||
Feasible and a strong architectural fit, but it is a multi-phase build with a
|
||||
fleet-wide blast radius. The single most important gate is P1: import the real
|
||||
manifests and prove BEHAVIORAL parity (same entries fire in the same order with
|
||||
the same detection/targeting), not byte-identity. Do not build the UI or touch a
|
||||
client until that parity holds. Three things separate a safe build from a
|
||||
dangerous one and must not be cut: behavioral-parity import (P1), immutable
|
||||
published snapshots with rollback before any client points at shopdb (P2/P4),
|
||||
and a dedicated `payloadsha256` for every HTTP/inline payload (section 5). If and
|
||||
when we proceed, this warrants a new ADR (ADR-012: GE-Enforce manifest
|
||||
ownership) capturing the desired-state model, the published-snapshot contract,
|
||||
the SMB/HTTP/inline payload + integrity model, and the fail-safe cache.
|
||||
|
||||
## 13. Execution plan (build order, gates, milestones)
|
||||
|
||||
Governing constraint: every step must be runnable and maintainable by average
|
||||
site IT, not just the original developer. Where an earlier draft implied expert
|
||||
machinery, this section simplifies it (and the model above already reflects
|
||||
those simplifications: one wide table, JSON-document snapshots, no row-mirroring).
|
||||
|
||||
### Phases and gates
|
||||
|
||||
- **P0 - Scaffold (S, ~0.5-1 day).** `flask plugin new geenforce`, structure
|
||||
copied from `plugins/measuringtools/`. Unlike bundled plugins' no-op migration
|
||||
anchors, this NEW plugin's `0001_geenforce_baseline` actually creates the
|
||||
tables and registers them in `PLUGIN_TABLE_OWNERS` (ADR-008). Deploy stays the
|
||||
standard `flask db upgrade` + `flask plugin upgrade-all`. Manifest:
|
||||
`api_prefix: /api/geenforce`, `default_enabled: false`, tight `core_version`.
|
||||
|
||||
- **P1 - Model + importer + parity harness (M, ~1-1.5 wk). THE GATE.** Order
|
||||
inside: tables -> `flask geenforce import-share` (reads common + every
|
||||
`gea-shopfloor-*` + preinstall.json, skips `.bak`, idempotent) -> exporter
|
||||
(rebuilds each scope's JSON from rows in `sortorder`) -> the parity harness
|
||||
(below). **GATE A:** `flask geenforce parity` prints PASS for all scopes. If
|
||||
it cannot pass, STOP the project. No API/UI/client work before Gate A.
|
||||
|
||||
- **P2 - Publish/snapshot/rollback + admin API + export-to-share (M, ~1.5-2 wk).**
|
||||
Publish freezes rendered JSON into `manifestpublishedversions`. CRUD per
|
||||
section 6. Plus `flask geenforce export-share` + an "Export to share" button
|
||||
that writes each scope's published JSON to the share after backing up the old
|
||||
file to `_meta/history/`. Engine, dispatcher, share layout, payloads, PCs all
|
||||
untouched. **GATE B = Milestone 1** (below).
|
||||
|
||||
- **P3 - Frontend editor (L, ~2-3 wk; parallel with P4 after P2 API freezes).**
|
||||
Expand `PCTypeMappingSettings.vue` per section 7, in three shippable
|
||||
increments; Move Up/Down not drag; the simulator.
|
||||
|
||||
- **P4 - Client fetch + shadow mode (M effort + soak time; needs P2, not P3).**
|
||||
Week-1 spike: a ~20-line PS1 on ONE canary PC proves SYSTEM-context HTTP auth +
|
||||
TLS trust before any real client change. Then `GE-Enforce.ps1` fetches JSON to
|
||||
a local cache and hands the file to `Install-FromManifest.ps1` unchanged;
|
||||
shadow mode installs from the share but logs any diff vs shopdb; ETag +
|
||||
last-known-good cache from day one. **GATE C:** zero shadow diffs across one PC
|
||||
of every pctype for >= 20 cycles.
|
||||
|
||||
- **P5 - Read cutover (S effort, M calendar).** Per-scope flip, canary first via
|
||||
`TargetHostnames`. Payloads stay `smb`. Rollback = dispatcher revert; share
|
||||
export continues as break-glass. **GATE D:** all scopes cut over.
|
||||
|
||||
- **P6 - Payload migration (S per entry, optional forever).** Small configs to
|
||||
`inline` (verified by `payloadsha256`); MSIs stay on SMB. Each entry
|
||||
independently revertible (flip `payloadsource`).
|
||||
|
||||
Hard ordering: P0 -> P1 -> P2 -> rest. **Snapshots (P2) MUST precede any client
|
||||
pointing at shopdb (P4).** P3 and P4 parallelize. Preinstall stays share-sourced
|
||||
through at least Milestone 1 (no token pre-enrollment; export writes
|
||||
`preinstall.json` too, so it is authored-in-shopdb for free with no client risk).
|
||||
|
||||
### The P1 parity harness (concrete, IT-re-runnable)
|
||||
|
||||
`plugins/geenforce/parity.py` + a CLI, also wrapped as a CI test. Two checks per
|
||||
scope, output one readable line per scope (`entries N/N identical profiles M/M
|
||||
same-fire PASS`), exit 0/1, prints the first differing entry/field on fail:
|
||||
|
||||
1. **Lossless field check (order-preserving).** Canonicalize each entry to
|
||||
exactly the fields the engine reads (Name, Type, the payload fields, all
|
||||
Detection*, the filter arrays, `_CmmVersion`, InUseCheck, preinstall flags);
|
||||
exclude `_comment` and key order (documentation, not behavior). Compare the
|
||||
ordered lists position by position.
|
||||
2. **Same-entries-fire-in-same-order.** Re-implement in ~120 lines of Python the
|
||||
engine's four filter functions exactly as written in `Install-FromManifest.ps1`
|
||||
(`Test-PCTypeMatches` incl. the alias groups at lines 463-475, `"*"`, and
|
||||
`<Type>-<SubType>`; `Test-HostnameMatches` exact + `-like`;
|
||||
`Test-MachineNumberMatches`; `Test-CmmVersionMatches`). For each machine-
|
||||
profile fixture, run BOTH manifests through it and assert the identical
|
||||
ordered list of entry names that pass all filters. Detection itself is not
|
||||
executed - check 1 already proved detection fields identical, so identical
|
||||
inputs to detection are guaranteed. This pair proves losslessness without
|
||||
byte-diffing.
|
||||
|
||||
Fixtures (`plugins/geenforce/parityfixtures.json`, ~16-18 profiles): one per
|
||||
pctype; CMM version variants `2016/2019/2026`/empty; collections machine-number
|
||||
variants (a credentialed bay, an MTConnect bay, neither); legacy-alias profiles
|
||||
(`Standard`+`Machine`, `CMM`) to exercise the alias graph both ways; a `WJS-*`
|
||||
hostname-wildcard profile; preinstall profiles including one that hits
|
||||
`PCTypesStrict`. Watch-items the harness must handle: empty `Applications: []`
|
||||
scopes (4 exist), entries with NO `DetectionMethod` (fire every run), and the
|
||||
`regvalue` literal typing.
|
||||
|
||||
### First slice: one vertical through `gea-shopfloor-cmm`
|
||||
|
||||
Only 4 entries but hits every hard part - MSI type, Registry detection with and
|
||||
without a pinned value, nested InUseCheck with Processes[], and the `_CmmVersion`
|
||||
gate. Tables: scopes, entries, entrypctypes, inusechecks + processes,
|
||||
publishedversions, pctypealiases. `flask geenforce import-share --scope
|
||||
gea-shopfloor-cmm`; `flask geenforce publish gea-shopfloor-cmm`; one endpoint
|
||||
`GET /api/geenforce/manifest?pctype=gea-shopfloor-cmm` serving the published
|
||||
snapshot (fat-client, ETag, collector-style `X-API-Key`/PAT auth reusing
|
||||
`shopdb/core/api/collector.py`). **Done =** parity PASS for cmm; the endpoint's
|
||||
JSON fed to `Install-FromManifest.ps1` on a bench CMM PC logs `4 skipped`
|
||||
identically to the share manifest; editing a draft does NOT change the served
|
||||
bytes but publishing does, and rollback restores the prior published bytes;
|
||||
unauth = 401, wrong-scope = 401.
|
||||
|
||||
### Milestone 1 (the recommended first stop)
|
||||
|
||||
End of P2 plus the publish/scope-list slice of P3: **manifests are authored and
|
||||
published in shopdb, exported to the share by a button, and the engine,
|
||||
dispatcher, share layout, payloads, and every PC are completely unchanged.**
|
||||
That delivers the real pain relief - validated editing instead of hand-edited
|
||||
JSON, version history, one-click rollback (republish + re-export), desired-state
|
||||
data sitting next to collector data - at ZERO client risk, with a rollback any
|
||||
IT tech already knows (restore the `_meta/history` backup file). Natural point to
|
||||
write ADR-012 with real experience behind it. P4/P5 (HTTP fetch, cutover) are a
|
||||
separately green-lit second milestone.
|
||||
|
||||
### Ranked risks / fail-fast
|
||||
|
||||
1. **Generated-JSON vs engine drift (fleet-wide mis-install).** Parity harness
|
||||
first; CI re-proves parity against checked-in real manifests on every
|
||||
exporter change; pin lib >= 2.6.
|
||||
2. **Serving a half-finished draft.** Structural: client reads only
|
||||
`iscurrent` snapshots; test asserts a draft edit leaves served bytes
|
||||
unchanged. Must exist before P4.
|
||||
3. **Availability coupling.** Last-known-good local cache in the first client
|
||||
prototype; shadow test blocks shopdb and confirms enforce-from-cache + WARN.
|
||||
4. **SYSTEM HTTP auth + TLS trust.** The ~20-line canary spike in P4 week 1,
|
||||
before the real client change. Hours of cost; if it fails, Milestone 1 still
|
||||
delivers full value.
|
||||
5. **Alias-graph drift.** Seed pins a lib version; harness legacy-name profiles
|
||||
fail loudly on divergence; new-lib runbook includes "update the alias seed".
|
||||
6. **Preinstall has no pre-enrollment token.** Keep share-sourced through
|
||||
Milestone 1/2; decide later.
|
||||
7. **Editor scope creep.** Three shippable increments; buttons over drag; reuse
|
||||
JSON preview.
|
||||
|
||||
### IT operability (day-to-day runbook, proving the design is manageable)
|
||||
|
||||
All in Settings > Imaging PC Types. No PowerShell, no SQL, no share edits.
|
||||
- **Add an app to a PC type:** open the PC type, Add Entry, pick Type (fields
|
||||
adapt), fill installer + detection + targeting, Move Up/Down to order, Preview
|
||||
(+ simulator), Publish with a note. PCs pick it up next 5-min cycle.
|
||||
- **Bump a version:** drop the new MSI in the scope's `apps/` on the share,
|
||||
update the entry's Installer + Detection value, Preview, Publish.
|
||||
- **Roll back a bad publish:** History -> pick last-good version -> Roll Back
|
||||
(during Milestone 1 also click Export to Share).
|
||||
- **Canary a risky change:** add the one test PC under Target Hostnames, Publish;
|
||||
when happy, remove the filter and Publish again.
|
||||
- **Check "did PC Y get app X":** the simulator with that PC's type/machine
|
||||
number/CMM version shows exactly which entries apply and why others are filtered.
|
||||
- **See revision history / who changed what:** the PC type's History tab lists
|
||||
every published version (date, author, note) with a Roll Back on each; the
|
||||
Audit Logs page shows the finer-grained draft edits (who touched which entry
|
||||
field, when) between publishes.
|
||||
219
docs/proposals/printedparts-plugin.md
Normal file
219
docs/proposals/printedparts-plugin.md
Normal file
@@ -0,0 +1,219 @@
|
||||
<!-- docmeta
|
||||
purpose: explanation
|
||||
audience: developer
|
||||
status: historical
|
||||
summary: The design behind the printedparts plugin and kiosk. Built; kept as the record of why.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# Proposal: printedparts plugin (3D-printed parts storefront + kiosk)
|
||||
|
||||
Status: BUILT. The plugin ships as `plugins/printedparts/`, kiosk route
|
||||
included. This page is the design behind it, kept as the record of why, and it
|
||||
is also the reference design for the plugin-development lab in
|
||||
`docs/PLUGIN-LAB-PRINTEDPARTS.md`. It is NOT current guidance: where it and the
|
||||
shipped plugin disagree, the code wins.
|
||||
|
||||
## 1. Problem
|
||||
|
||||
The 3D-printer engineers stock bins of printed parts (fixtures, clips, covers,
|
||||
spacers). Anyone on the floor can take parts, so stock silently runs out and
|
||||
nobody knows who took what or how fast items burn down. They need:
|
||||
|
||||
- a catalog ("storefront") of printable items: photo, description, quantity on
|
||||
hand;
|
||||
- a barcode label per item (1in x 0.5in) stuck on each bin;
|
||||
- a touch-screen kiosk: scan the bin barcode, scan your badge, enter how many
|
||||
you took, submit;
|
||||
- restock and correction flows for the engineers;
|
||||
- stock monitoring plus consumption metrics.
|
||||
|
||||
## 2. Shape: standalone model plugin, NOT an asset type
|
||||
|
||||
These are quantity-based consumables: one row represents a *kind* of part with
|
||||
a count, not an individually tracked machine. ADR-001 assets are one-row-per-
|
||||
physical-thing (a PC, a printer). So printedparts follows the
|
||||
knowledgebase/usb shape - own tables, own blueprint, no AssetType row - and
|
||||
does NOT join the asset-label TYPE_CONFIG; it ships its own print view the way
|
||||
USB labels do.
|
||||
|
||||
Item identity: `itemcode`, generated `3DP-<zero-padded id>` (prefix
|
||||
configurable via setting `printedparts_code_prefix`). Short, CODE128-friendly,
|
||||
human-readable. This is what the bin label encodes.
|
||||
|
||||
## 3. Data model (2 tables, LOCKED naming, per-plugin Alembic)
|
||||
|
||||
### printeditems
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| printeditemid | int PK autoincrement | |
|
||||
| itemcode | varchar(20) unique, indexed | generated on create |
|
||||
| itemname | varchar(120) NOT NULL | |
|
||||
| itemdescription | varchar(500) | brief description |
|
||||
| imageurl | varchar(255) | served upload, models.py pattern |
|
||||
| quantityonhand | int NOT NULL default 0 | cached; ledger is truth |
|
||||
| lowstockthreshold | int NOT NULL default 5 | per-item, seeds from setting |
|
||||
| binlocation | varchar(100) | where the bin lives |
|
||||
| printnotes | mediumtext | material, print time, slicer file path |
|
||||
| isactive | tinyint(1) | soft retire |
|
||||
| createddate / modifieddate | datetime | AuditMixin/BaseModel |
|
||||
|
||||
### printeditemtransactions (the ledger - source of truth)
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| transactionid | int PK | |
|
||||
| printeditemid | int FK -> printeditems CASCADE, indexed | |
|
||||
| transactiontype | varchar(10) NOT NULL | take / restock / adjust |
|
||||
| quantitychange | int NOT NULL | negative for take, signed for adjust |
|
||||
| employeesso | varchar(20) NOT NULL, indexed | who (badge-resolved) |
|
||||
| employeename | varchar(120) | resolved at write time (USB pattern) |
|
||||
| reason | varchar(255) | required for adjust |
|
||||
| transactiondate | datetime NOT NULL default naive-UTC, indexed | |
|
||||
|
||||
Invariants: `quantityonhand` = sum of `quantitychange` (enforced by writing
|
||||
both in one session/commit; an `adjust` can never drive it below 0 - reject).
|
||||
Every write records WHO via badge scan; there is no anonymous mutation.
|
||||
|
||||
Both tables registered in `PLUGIN_TABLE_OWNERS`
|
||||
(`shopdb/plugins/alembic_template.py`); migration 0001 is a REAL baseline
|
||||
(measuringtools pattern - hand-written `op.create_table`, no cross-schema FK
|
||||
so `create_plugin_tables` would also work, but write the ops explicitly for
|
||||
the exercise).
|
||||
|
||||
## 4. Badge resolution (reuse the USB contract exactly)
|
||||
|
||||
Same input shapes as `plugins/usb/api/routes.py`:
|
||||
- all digits -> SSO;
|
||||
- `0<digits>BZ` (case-insensitive) -> physical badge wrapping a PayNo;
|
||||
- resolution to a display name via the employees plugin directory
|
||||
(`DirectoryEmployee`, selfhosted mode) with graceful "" fallback.
|
||||
|
||||
Extract-or-copy decision for the lab: copy the small `_PAYNO_BADGE` regex +
|
||||
lookup into the plugin (contract-pure, no cross-plugin import of usb).
|
||||
Resolution happens SERVER-side on the kiosk endpoint - the kiosk client never
|
||||
supplies a name, only the raw badge string.
|
||||
|
||||
Manifest `dependencies: ["employees"]` (name lookup). Badge that resolves to
|
||||
no employee: configurable policy setting `printedparts_unknown_badge`
|
||||
(`allow` = record SSO with empty name, `deny` = 422). Default deny.
|
||||
|
||||
## 5. API surface (blueprint at /api/printedparts)
|
||||
|
||||
Authenticated management (JWT + permission):
|
||||
| route | method | permission |
|
||||
|---|---|---|
|
||||
| `/items` | GET list (search, paginate, lowstock filter) | open read (jwt optional) |
|
||||
| `/items/<id>` | GET detail + recent transactions | open read |
|
||||
| `/items` | POST create (mints itemcode) | printedparts.create |
|
||||
| `/items/<id>` | PUT update | printedparts.edit |
|
||||
| `/items/<id>` | DELETE soft-retire | printedparts.delete |
|
||||
| `/items/<id>/image` | POST/DELETE upload/remove | printedparts.edit |
|
||||
| `/image/<filename>` | GET serve | public (models.py pattern) |
|
||||
| `/items/<id>/restock` | POST {quantity, badge} | printedparts.restock |
|
||||
| `/items/<id>/adjust` | POST {quantitychange, reason, badge} | printedparts.restock |
|
||||
| `/items/<id>/transactions` | GET history, ?format=csv | open read |
|
||||
|
||||
Kiosk (unauthenticated, notifications/employees open-endpoint precedent):
|
||||
| route | method | body |
|
||||
|---|---|---|
|
||||
| `/kiosk/item/<itemcode>` | GET | item summary by scanned code |
|
||||
| `/kiosk/take` | POST | {itemcode, badge, quantity} |
|
||||
|
||||
`/kiosk/take` validation: item exists + active; quantity 1..quantityonhand
|
||||
(clamp/reject configurable? no - reject with clear message, kiosk shows it);
|
||||
badge resolves per policy. Writes ledger row (negative) + decrements cached
|
||||
quantity in one commit. Rate of abuse is low (plant floor), but the endpoint
|
||||
only ever DECREMENTS stock with a recorded badge - it cannot edit the catalog.
|
||||
|
||||
Permissions declared via `get_permissions()`: printedparts.view/create/edit/
|
||||
delete/restock (category `printedparts`), seeded on install/enable.
|
||||
|
||||
## 6. Frontend
|
||||
|
||||
Management pages (scaffold output, standard layout, master templates
|
||||
PrintersList/PrinterDetail):
|
||||
- `PrintedItemsList.vue` - table: image thumb, code, name, qty (red badge when
|
||||
<= threshold), bin; filters: search, low-stock-only; row click -> detail.
|
||||
- `PrintedItemDetail.vue` - hero image + fields, transaction history table,
|
||||
restock/adjust buttons (modal w/ quantity + badge + reason).
|
||||
- `PrintedItemForm.vue` - create/edit incl. image upload, threshold, bin.
|
||||
- Router file `router/routes/printedparts.js`, list/detail plugin-gated only,
|
||||
new/edit + requiresAuth (ADR-009, usb.js precedent).
|
||||
- Nav via `get_navigation_items()` -> "3D Parts".
|
||||
|
||||
Kiosk (net-new, top-level route `/parts-kiosk`, NO requiresAuth, outside
|
||||
AppLayout - shopfloor precedent):
|
||||
- Full-screen, 3-step flow: (1) SCAN ITEM - a focused invisible input catches
|
||||
the keyboard-wedge scan of the bin barcode, shows item card w/ photo + qty;
|
||||
(2) SCAN BADGE - same wedge input pattern for the badge; (3) QUANTITY - big
|
||||
touch keypad (0-9, clear, backspace - net-new component
|
||||
`TouchKeypad.vue`) + TAKE button. Success screen w/ remaining count, auto
|
||||
reset after a few seconds. All state client-side; one POST at the end.
|
||||
- Scanner UX rule: keyboard-wedge scanners type the code + Enter. A hidden
|
||||
always-focused input with @keydown.enter handles both scans; on-screen
|
||||
prompt tells the user what to scan. Touch fallback: item search + manual
|
||||
badge entry (small link, for damaged labels).
|
||||
|
||||
Labels (own print view, USBLabelBatch precedent):
|
||||
- `/print/printedparts-labels` public print route.
|
||||
- NEW physical size: 1in x 0.5in stock -> `@page { size: 1in 0.5in; margin: 0 }`
|
||||
one label per page (label printers feed roll stock; per-page = per-label).
|
||||
Layout: CODE128 barcode (JsBarcode, ~0.9in x 0.28in, displayValue false) +
|
||||
itemcode text under it (~7pt) + optional item name truncated. QR variant
|
||||
offered but barcode is default at this size (a 0.4in QR is at the edge of
|
||||
scanner tolerance; CODE128 of `3DP-0042` is comfortable).
|
||||
- Batch mode: pick items -> one label per page sequence for roll printers;
|
||||
also a ULINE mini-grid fallback for sheet printers (reuse mini72 pattern).
|
||||
|
||||
## 7. Metrics / reports (get_reports hook)
|
||||
|
||||
- `printedparts-stock` - current stock levels w/ threshold flags (CSV).
|
||||
- `printedparts-consumption` - takes per item over a date range (CSV).
|
||||
- `printedparts-by-person` - takes grouped by employee (CSV).
|
||||
- Dashboard widget via `get_dashboard_widgets()`: low-stock item count.
|
||||
- Nice-to-have later: burn-rate (avg takes/week per item + weeks-to-empty
|
||||
projection) - plain SQL over the ledger, add once basics work.
|
||||
|
||||
## 8. Settings (get_settings_cards, category printedparts)
|
||||
|
||||
| key | default | purpose |
|
||||
|---|---|---|
|
||||
| printedparts_code_prefix | 3DP | itemcode prefix |
|
||||
| printedparts_default_threshold | 5 | seed for new items |
|
||||
| printedparts_unknown_badge | deny | kiosk policy for unresolvable badges |
|
||||
|
||||
## 9. Manifest
|
||||
|
||||
name printedparts, version 0.1.0, api_prefix /api/printedparts,
|
||||
core_version ">=0.12.0,<1.0.0", dependencies ["employees"],
|
||||
default_enabled false (site opts in - USB precedent).
|
||||
|
||||
## 10. Explicitly out of scope (v1)
|
||||
|
||||
- Reservations/approvals, per-item cost, print-queue integration, multi-bin
|
||||
per item, email low-stock alerts (the reports + dashboard widget cover
|
||||
monitoring; alerting can ride the existing report-email endpoint later).
|
||||
|
||||
## 11. Risks / decisions taken
|
||||
|
||||
- Cached quantity vs ledger drift: single-commit writes + a reconcile query in
|
||||
the stock report (flags items where cache != ledger sum).
|
||||
|
||||
### Decision: the kiosk take endpoint is an unauthenticated WRITE
|
||||
|
||||
This is the first open mutation in the product - every existing kiosk
|
||||
endpoint (notifications, employees, shopfloor) is a read, and the closest
|
||||
write (USB checkout) is JWT + permission gated. Accepted deliberately, on
|
||||
these grounds, and any future open-write endpoint must meet the same bar:
|
||||
|
||||
1. Decrement-only: it can reduce stock of an active item, nothing else - no
|
||||
catalog edits, no restocks, no reads it does not already expose.
|
||||
2. Fully attributed: it refuses to act without a badge that resolves per the
|
||||
site policy; every action lands in the ledger with SSO + name + time.
|
||||
3. Bounded blast radius: worst case is stock counts driven low, which the
|
||||
ledger makes visible and reversible (adjust with reason).
|
||||
4. Physically rate-limited: it exists for a touch screen on the shop floor;
|
||||
there is nothing to enumerate and nothing returned worth scraping.
|
||||
- 1x0.5in QR marginal: default to CODE128 barcode.
|
||||
- Not an Asset: no floor-map plotting or warranty for items. If a site later
|
||||
wants bins on the floor map, revisit via get_map_overlays (ADR-010).
|
||||
346
docs/proposals/printer-assignment.md
Normal file
346
docs/proposals/printer-assignment.md
Normal file
@@ -0,0 +1,346 @@
|
||||
<!-- docmeta
|
||||
purpose: explanation
|
||||
audience: developer
|
||||
status: historical
|
||||
summary: The 2026-08 design making printers a property of the machine. Shipped in v0.11.3 and v0.12.0.
|
||||
loadwith: none
|
||||
-->
|
||||
|
||||
# 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.
|
||||
Reference in New Issue
Block a user