Collect what bays actually have, separately from what they are told to have
ShopDB knew what a bay SHOULD have and nothing about what it DOES. Adding the observed half makes a rollout a review instead of a typing exercise: the floor reports itself in, you look, and you adopt. The collection uses the mechanism that already exists rather than a new one. POST /api/collector/printers dispatches to the printers plugin's apply_collector_payload, the same ADR-006 hook the computers and backups plugins implement. New client script, new plugin-owned table, no new transport and no new credential. OBSERVED AND ASSIGNED STAY APART, and that is the point rather than a detail. A collector report can never write an assignment row: _reconcile_edges is the only function that writes usesprinter/defaultprinter, it has two call sites, and both are authenticated routes a human calls. If a drifted bay's own state were allowed to become what it is told to install, every configuration error would become permanent the next time that PC checked in. Seeding an assignment from observed state is explicit - POST /assignments/seed-from-observed - because a rollout adopts many machines at once. It routes through the same _reconcile_edges as the editor, so there is one write path with two doors, and a queue matching no known printer is REFUSED rather than guessed into an assignment. That last rule is the lesson from the measuring tools: adopting on a weak key produced 43 duplicate instruments. Two fixes on top of what the agents built. The replace deleted a host's previous rows by exact case-folded name while the read path treats a short name and its FQDN as one machine, so a PC that changed spelling appeared to hold every queue twice - which reads as drift that is not there. And the client sent 'reportedat' where the declared schema said 'observedat'. Also here: the legacy loader now imports machines.printerid, the classic system's record of each machine's default printer, which it silently dropped - the production import would have lost every one. And Set-ShopdbPrinters.ps1 finally registers the per-user logon task, staging Apply-ShopdbDefaultPrinter.ps1 to C:\ProgramData first because the share it lives on is mounted only during the enforcement cycle and the task runs at logon when it is gone. VALIDATED ON WINDOWS 11 (build 26200), not just on Linux pwsh, which parses these scripts happily and executes none of the spooler branches. The reporter: posts a correct payload with the X-API-Key header; resolves BaseUrl and CollectorKey from HKLM when given no arguments; suppresses the virtual queues by port; resolves port addresses; and reads the CONSOLE USER's default out of HKU rather than SYSTEM's own, which is a different and usually wrong answer. Two results matter more than the rest. With the spooler stopped, both the cmdlet and the CIM path fail and the script posts NOTHING - verified against a capture server that recorded zero requests, where an empty list would instead have erased that host's observed rows and read as a bay that lost its printers. A genuinely empty host still posts [], because that is a real and different fact. The logon task registers as the Users group at Limited, and falls back to the well-known SID S-1-5-32-545 when the group name will not resolve, as it will not on localised Windows. It was then run with the source directory RENAMED AWAY, to stand in for the share being unmounted, and it still moved the user's default - which is the whole reason the script is staged to C:\ProgramData rather than run from where it lives. The guarantees against damage were re-checked rather than assumed: an empty assignment changes nothing, an unreachable server changes nothing, -WhatIfOnly leaves no queue, no task, no staged file and no registry value behind, and a drifted queue is repointed IN PLACE with Set-Printer so whoever has it as their default keeps it. Not covered by any of this: the driver-staging path, which needs a real vendor package rather than the class drivers a VM ships with.
This commit is contained in:
@@ -8,6 +8,8 @@ a real caller (the GE-Enforce fleet agent) to it.
|
||||
- Server code: `shopdb/core/api/collector.py`
|
||||
- Computers schema + upsert: `plugins/computers/plugin.py`
|
||||
(`get_collector_schema` / `apply_collector_payload`)
|
||||
- Printers schema + replace (observed print queues): `plugins/printers/plugin.py`
|
||||
(same two hooks)
|
||||
- Contract rationale: `docs/adr/ADR-006-collector-contract.md`
|
||||
|
||||
---
|
||||
@@ -321,6 +323,137 @@ classic reporter posts a full `networkInterfaces` array; the collector accepts
|
||||
only one `ipaddress`, so pick the corp/routable NIC (see the corp-range gate in
|
||||
the PowerShell below).
|
||||
|
||||
### Printers plugin field mapping (observed print queues)
|
||||
|
||||
`POST /api/collector/printers`. The other half of the printer story. ShopDB has
|
||||
always known what a bay SHOULD have - `GET /api/printers/for-host/<hostname>`,
|
||||
applied by `Set-ShopdbPrinters.ps1` - and has never known what it actually has.
|
||||
This payload is that missing half. Same dispatcher, same auth, same audit row as
|
||||
the computers collector; only the payload and the plugin differ.
|
||||
|
||||
- Schema + apply: `get_collector_schema` / `apply_collector_payload` in
|
||||
`plugins/printers/plugin.py`.
|
||||
- Client: `plugins/printers/client/Report-PrintersToShopDB.ps1` (SYSTEM context,
|
||||
`Type=PS1` / DetectionMethod `Always` manifest entry, logs to
|
||||
`C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`, always exits 0).
|
||||
- Storage: `printerobservedqueues`, one row per observed queue, owned by the
|
||||
printers plugin. It is a separate table from the assignment on purpose - see
|
||||
"Observed is not assigned" below.
|
||||
|
||||
The identity field is `hostname`. Unlike the computers payload this one is NOT
|
||||
patch-style: `queues` is required, and the reported set replaces everything
|
||||
previously recorded for that host.
|
||||
|
||||
| Payload field | Type | Server behaviour (`apply_collector_payload`) |
|
||||
|---|---|---|
|
||||
| `hostname` (required) | string | Identity of the report. `COMPUTERNAME` or its FQDN; matched case-insensitively. Rows are keyed by the NAME, so a bay that reports before its PC record exists still records everything - the report resolves to an asset the moment that record appears. An unknown hostname is a warning, never an error. |
|
||||
| `queues` (required) | array of objects | Every real print queue on the host. This REPLACES the host's previous set. An empty array is a valid report meaning "this bay has no queues" and clears the host's rows. A missing key is rejected (400), because one client bug that dropped the field would otherwise erase the fleet's observed state host by host. |
|
||||
| `queues[].queuename` (required) | string | Windows printer name. A queue with no name is skipped with a warning; a repeated name within one report is dropped with a warning (Windows cannot hold two queues of one name). |
|
||||
| `queues[].drivername` | string | Driver name verbatim, as the INF spells it. Compared against the assignment's driver to detect drift. |
|
||||
| `queues[].portname` | string | Windows port name. Also tried as a match key, since a port created outside the client is usually named after the host address. |
|
||||
| `queues[].portaddress` | string | `PrinterHostAddress` of a TCP/IP port - an IP or FQDN. The PRIMARY key for matching a queue to a printer asset. Omit it for a non-TCP port (USB, WSD, redirected); such a queue is still worth reporting and matches on name alone. |
|
||||
| `queues[].isdefault` | boolean | True on the one queue that is the user's default. If several arrive true, the first is kept and the rest are cleared with a warning - a bay has exactly one default, and keeping both would leave a seed picking one at random. |
|
||||
| `queues[].isshared` | boolean | True when the queue is shared off this PC. Recorded, not acted on. |
|
||||
| `observedat` | ISO-8601 datetime | Accepted and ignored, as is any other client timestamp. The server stamps `observedat` at ingest, one value for the whole report, so a bay with a wrong clock cannot report itself fresh or stale. |
|
||||
|
||||
Response `data` is the standard collector shape: `action` is always `updated`
|
||||
(this endpoint replaces rows and creates no asset - calling an identical
|
||||
re-report `noop` would hide that the bay is still checking in), `assetid` is the
|
||||
resolved PC or `null`, `extra.queuecount` is how many rows were stored, and
|
||||
`warnings` carries the soft problems above.
|
||||
|
||||
Schema source of truth: `get_collector_schema` in `plugins/printers/plugin.py`.
|
||||
If you change the payload, change it there and re-check this table.
|
||||
|
||||
```
|
||||
POST /api/collector/printers
|
||||
X-API-Key: SECRET
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"hostname": "WORKSTATION01",
|
||||
"queues": [
|
||||
{
|
||||
"queuename": "Bay Label Printer",
|
||||
"drivername": "Generic / Text Only",
|
||||
"portname": "IP_192.0.2.40",
|
||||
"portaddress": "192.0.2.40",
|
||||
"isdefault": true
|
||||
},
|
||||
{
|
||||
"queuename": "Office Laser",
|
||||
"drivername": "HP Universal Printing PCL 6",
|
||||
"portname": "IP_192.0.2.41",
|
||||
"portaddress": "192.0.2.41",
|
||||
"isdefault": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### The latest report replaces the previous one
|
||||
|
||||
This is CURRENT STATE, not history. Each report deletes every row previously
|
||||
recorded for that hostname and writes the reported set in its place, inside the
|
||||
dispatcher's transaction. Nothing accumulates, "what does this bay have" is
|
||||
never a question about time, and a queue removed from a bay disappears from
|
||||
ShopDB on the next cycle without anyone tidying up.
|
||||
|
||||
That has one sharp edge, and it belongs to the client: an empty `queues` array
|
||||
is a legitimate report that wipes the host's rows. A client whose enumeration
|
||||
FAILED must therefore send nothing at all rather than an empty list. Reporting
|
||||
nothing loses one cycle; reporting `[]` after a WMI hiccup deletes real state
|
||||
and reads as a bay that lost its printers. `Report-PrintersToShopDB.ps1` tracks
|
||||
this with an `$enumerated` flag and exits without posting when both the cmdlet
|
||||
and the WMI fallback failed.
|
||||
|
||||
#### Observed is not assigned
|
||||
|
||||
Stated plainly, because it is the whole point of keeping two tables:
|
||||
|
||||
**A collector report NEVER becomes an assignment.** Nothing on this path writes
|
||||
a `usesprinter` or `defaultprinter` row. The moment a drifted bay's observed
|
||||
state is treated as correct, enforcement stops meaning anything - a bay that
|
||||
installed the wrong printer would make itself right simply by reporting it.
|
||||
|
||||
Observed state becomes assigned state only when a person asks for it, through
|
||||
`POST /api/printers/assignments/seed-from-observed/<assetid>` (requires
|
||||
`printers.edit`), after reviewing the comparison. That route refuses rather than
|
||||
guesses: it will not seed from an ambiguous host, will not seed queues that
|
||||
match no printer unless told to, and will not write an empty set.
|
||||
|
||||
#### Matching, and what "unknown" means
|
||||
|
||||
Nothing is matched at ingest - the raw observed strings are stored as reported,
|
||||
and resolution to a printer asset happens at READ time, so a printer added to
|
||||
ShopDB tomorrow matches yesterday's report without the bay reporting again.
|
||||
|
||||
At read time each queue is matched by PORT ADDRESS first (an IP or FQDN names
|
||||
one device unambiguously), then by queue name. There is no fuzzier fallback: a
|
||||
queue that matches nothing is reported as `unknown` rather than guessed, because
|
||||
a wrong match seeds a wrong assignment, which is worse than no assignment.
|
||||
|
||||
#### Reading it back
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|---|---|
|
||||
| `GET /api/printers/observed/<hostname>` | What the host last reported, every queue classified against the assignment it resolves to (`matching`, `drifted`, `extra`, `missing`, `unknown`), with `observedat`, a summary, and a `seedcandidate` preview. Read-only. Needs `printers.view`. |
|
||||
| `POST /api/printers/assignments/seed-from-observed/<assetid>` | The one path from observed to assigned, human-triggered. Needs `printers.edit`. Normally posted against the MACHINE, so the assignment survives a reimage. |
|
||||
|
||||
#### Key delivery for the printers reporter
|
||||
|
||||
The reporter reads its server and its key from the registry the enforcement
|
||||
client already provisions (`HKLM:\SOFTWARE\GE\ShopDB`, values `BaseUrl` and
|
||||
`CollectorKey`), or from `-ApiUrl` / `-ApiKey` in the manifest entry's `Args`.
|
||||
Nothing is baked into the script or the manifest JSON on the share - the same
|
||||
rule as the computers reporter, for the same reason.
|
||||
|
||||
Server side, the key resolves per-plugin first: set `COLLECTOR_API_KEY_PRINTERS`
|
||||
if you scope keys per collector, or rely on the shared `COLLECTOR_API_KEY`. A
|
||||
site that has scoped the computers key per-plugin and set no printers key gets
|
||||
401 on this endpoint until one of the two is in place. A `collector.ingest`
|
||||
managed token works here exactly as it does for computers.
|
||||
|
||||
---
|
||||
|
||||
## 2. Breaking change: querystring `api_key` removed
|
||||
@@ -703,8 +836,13 @@ fleet's key is scoped to the computers collector only.
|
||||
| `No collector registered for plugin computers` | 404 | The computers plugin is disabled or not loaded on that instance. Enable it. |
|
||||
| `hostname is required` / `No data provided` | 400 | Empty body, non-JSON body, or missing identity field. Check `-ContentType 'application/json'` and that `hostname` is set. |
|
||||
| `Internal error processing collector payload` | 500 | Generic by design - the server does not leak the cause to the caller. The real error (DB, unexpected exception) is in the server log (`Collector upsert failed for computers`). Check there. |
|
||||
| `No collector registered for plugin printers` | 404 | The printers plugin is disabled or not loaded on that instance. Enable it. A lean site without it simply does not collect observed queues. |
|
||||
| `queues is required; send an empty array for a host with no queues` | 400 | The printers payload omitted `queues` entirely. Absent and empty are not the same thing here - see "The latest report replaces the previous one". |
|
||||
| A bay's observed queues all vanished | 200 | It reported `queues: []`, which legitimately clears the host. Check the client log for `posting NOTHING` (a failed enumeration correctly sends nothing) versus `reporting an empty set`. If the bay really has queues, the enumeration on that host is the thing to fix. |
|
||||
| Observed queues show as `unknown` | 200 | The queue matched no printer asset by port address or by name. Add the printer to ShopDB (with its IP) and re-read - matching happens at read time, so the bay does not need to report again. Never seeded automatically, by design. |
|
||||
| `warnings` present but `action` is created/updated | 200 | Soft issues only; the row WAS written. Common: unmapped `pctype` (fix the pctypemap setting), unknown `osname` (add it to the `operatingsystems` vocab), unknown app name, `pcsubtype` not stored. No action needed unless the warning matters to you. |
|
||||
|
||||
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`.
|
||||
Client-side log for the fleet reporter: `C:\Logs\Shopfloor\collector-YYYYMMDD.log`;
|
||||
for the printer-queue reporter: `C:\Logs\Shopfloor\report-printers-YYYYMMDD.log`.
|
||||
Server-side: the Flask app log (the collector logs upsert failures and per-plugin
|
||||
schema failures there).
|
||||
|
||||
@@ -23,7 +23,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
||||
|
||||
| chain | head |
|
||||
|---|---|
|
||||
| core | `7d33_buildings_and_levels` |
|
||||
| core | `7d34_singular_relationship_types` |
|
||||
| backups | `backups0003clearlastseen` |
|
||||
| computers | `computers0001anchor` |
|
||||
| employees | `employees0002photo` |
|
||||
@@ -34,7 +34,7 @@ with `flask plugin upgrade-all`. Both are needed on a deploy.
|
||||
| network | `network0003prefix` |
|
||||
| notifications | `notifications0005boardorder` |
|
||||
| printedparts | `printedparts0004txnrev` |
|
||||
| printers | `printers0004drivervendor` |
|
||||
| printers | `printers0005observedqueues` |
|
||||
| slides | `slides0001anchor` |
|
||||
| usb | `usb0002dropmachineid` |
|
||||
| warranty | `warranty0002proof` |
|
||||
@@ -85,6 +85,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
|
||||
|
||||
## Size
|
||||
|
||||
- test functions defined: **1113** (parametrised cases collect higher)
|
||||
- documented API paths: **279** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
- test functions defined: **1140** (parametrised cases collect higher)
|
||||
- documented API paths: **282** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)
|
||||
|
||||
|
||||
@@ -2896,6 +2896,22 @@
|
||||
"purpose": "Desired printer set for one PC: its own usesprinter/defaultprinter relationships, or, when it has none, the ones inherited through its controls edge to the machine it drives. Each entry carries what a client needs to install the queue (queue name, hostname, ipaddress, port, drivername, driverlocation, isdefault, inherited). A known host with nothing assigned returns an empty list and a null default (the client's designed no-op); an unknown hostname, or a site without the computers plugin, is a 404.",
|
||||
"example": "curl http://localhost:5001/api/printers/for-host/workstation01"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/observed/<hostname>",
|
||||
"auth": "permission:printers.view",
|
||||
"params": "path: hostname (case-insensitive, short name or FQDN, as the reporting client sent it); no query parameters",
|
||||
"purpose": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST /api/collector/printers), each queue judged against the resolved assignment it SHOULD have. Read-only - nothing here changes an assignment, however wrong the bay looks. Every queue is classified matching / drifted (same printer, different port address or driver) / extra (a printer ShopDB knows, installed unassigned) / missing (assigned, not reported) / unknown (matches no printer, never guessed), each entry carrying both sides plus driftfields; the response adds observedat, the assignment source (own rows or the machine this PC controls), a per-classification summary, and a seedcandidate preview of what a seed would write. 404 only when the hostname has neither a report nor a computer record; a known PC that never reported returns an empty queue list.",
|
||||
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/workstation01"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/observed/for-asset/<asset_id>",
|
||||
"auth": "permission:printers.view",
|
||||
"params": "path: asset_id (a machine or a PC); no query parameters",
|
||||
"purpose": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with one block per reporting host. Assigned state lives on the MACHINE while observed state is reported by the PCs, so a machine answers with a block for each active PC that controls it (its own hostname first when the asset is itself a PC); blocks rather than one merged list because two PCs legitimately share one machine and the actionable part of drift is which box to walk to. Each block carries the same fields as the by-hostname route: queues classified matching / drifted / extra / missing / unknown, observedat, the assignment source, a summary, and a seedcandidate preview. Read-only. An asset nothing has reported for is an empty hosts list and a 200, not a 404.",
|
||||
"example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/for-asset/312"
|
||||
},
|
||||
{
|
||||
"method": "PUT",
|
||||
"path": "/api/printers/assignments/for-asset/<asset_id>",
|
||||
@@ -2904,6 +2920,14 @@
|
||||
"purpose": "Reconcile an asset's whole printer assignment in one write: soft-deletes usesprinter rows that went away, reactivates previously removed ones, creates new ones, and replaces the single defaultprinter row (exactly one per asset, optional, always one of the assigned printers). Removing an assignment has no side effects and never uninstalls anything on a client.",
|
||||
"example": "curl -X PUT -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"printerassetids\":[204,205],\"defaultprinterassetid\":204}' http://localhost:5001/api/printers/assignments/for-asset/312"
|
||||
},
|
||||
{
|
||||
"method": "POST",
|
||||
"path": "/api/printers/assignments/seed-from-observed/<asset_id>",
|
||||
"auth": "permission:printers.edit",
|
||||
"params": "path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those.",
|
||||
"purpose": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background job reaches this route, so a bay that installed the wrong printer can never make itself right by reporting it. Writes the matched observed queues through the same reconcile as PUT /printers/assignments/for-asset (usesprinter rows plus the single defaultprinter, set only when the observed default itself matched a printer). Returns what was written, the skipped queues, and warnings - including one when the target controls another asset, because its own rows then shadow that machine's assignment for good. Normally posted against the MACHINE so the assignment survives a reimage.",
|
||||
"example": "curl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"workstation01\"}' http://localhost:5001/api/printers/assignments/seed-from-observed/312"
|
||||
},
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/<printer_id>",
|
||||
|
||||
@@ -16801,6 +16801,108 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/observed/{hostname}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST...",
|
||||
"description": "The observed half of the loop and the mirror of /for-host: what this bay last REPORTED it has (POST /api/collector/printers), each queue judged against the resolved assignment it SHOULD have. Read-only - nothing here changes an assignment, however wrong the bay looks. Every queue is classified matching / drifted (same printer, different port address or driver) / extra (a printer ShopDB knows, installed unassigned) / missing (assigned, not reported) / unknown (matches no printer, never guessed), each entry carrying both sides plus driftfields; the response adds observedat, the assignment source (own rows or the machine this PC controls), a per-classification summary, and a seedcandidate preview of what a seed would write. 404 only when the hostname has neither a report nor a computer record; a known PC that never reported returns an empty queue list.\n\n**Auth:** permission:printers.view\n\n**Params:** path: hostname (case-insensitive, short name or FQDN, as the reporting client sent it); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/workstation01\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "hostname",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/observed/for-asset/{asset_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with...",
|
||||
"description": "The same observed-against-assigned comparison as /api/printers/observed/<hostname>, reached from an asset page, with one block per reporting host. Assigned state lives on the MACHINE while observed state is reported by the PCs, so a machine answers with a block for each active PC that controls it (its own hostname first when the asset is itself a PC); blocks rather than one merged list because two PCs legitimately share one machine and the actionable part of drift is which box to walk to. Each block carries the same fields as the by-hostname route: queues classified matching / drifted / extra / missing / unknown, observedat, the assignment source, a summary, and a seedcandidate preview. Read-only. An asset nothing has reported for is an empty hosts list and a 200, not a 404.\n\n**Auth:** permission:printers.view\n\n**Params:** path: asset_id (a machine or a PC); no query parameters\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/printers/observed/for-asset/312\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/assignments/for-asset/{asset_id}": {
|
||||
"put": {
|
||||
"tags": [
|
||||
@@ -16908,6 +17010,69 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/printers/assignments/seed-from-observed/{asset_id}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"plugin-printers"
|
||||
],
|
||||
"summary": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background...",
|
||||
"description": "The ONE path from observed state to assigned state, and a person has to ask for it: no collector, cycle or background job reaches this route, so a bay that installed the wrong printer can never make itself right by reporting it. Writes the matched observed queues through the same reconcile as PUT /printers/assignments/for-asset (usesprinter rows plus the single defaultprinter, set only when the observed default itself matched a printer). Returns what was written, the skipped queues, and warnings - including one when the target controls another asset, because its own rows then shadow that machine's assignment for good. Normally posted against the MACHINE so the assignment survives a reimage.\n\n**Auth:** permission:printers.edit\n\n**Params:** path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those.\n\n**Example:**\n```\ncurl -X POST -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"hostname\":\"workstation01\"}' http://localhost:5001/api/printers/assignments/seed-from-observed/312\n```",
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/SuccessEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or invalid credentials."
|
||||
},
|
||||
"403": {
|
||||
"description": "Authenticated, but not permitted."
|
||||
},
|
||||
"404": {
|
||||
"description": "No such record."
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "asset_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": true,
|
||||
"description": "path: asset_id (the machine or PC to write the assignment on); body, all optional: hostname (which report to seed from; omitted, the asset's own hostname or its single controlling PC), allowunmatched (bool, seed the matched queues anyway when some queue matches no printer). 404 when no named or derived host has reported; 409 when several controlling PCs have reported, or when any queue matches no printer and allowunmatched is unset; 400 when nothing matched, since writing the empty set would silently unassign the asset. Nothing is written on any of those."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/printers/{printer_id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -8,7 +8,7 @@ from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import db, cache, Setting, Asset, AssetType, Vendor, Model, Communication, CommunicationType, AssetRelationship, RelationshipType, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
|
||||
|
||||
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver
|
||||
from ..models import Printer, PrinterType, ModelSupply, PrinterDriver, PrinterObservedQueue
|
||||
from ..models.model_supply import SUPPLY_TYPES, SUPPLY_COLORS, CAPACITY_TIERS
|
||||
from ..services import (
|
||||
ZabbixService,
|
||||
@@ -1136,6 +1136,657 @@ def _reconcile_edges(sourceassetid, writetypeid, readtypeids, wantedtargets):
|
||||
relationshiptypeid=writetypeid,
|
||||
isactive=True))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Observed state (what a bay actually has)
|
||||
# =============================================================================
|
||||
|
||||
# The other half of the loop. The assignment says what a host SHOULD have; the
|
||||
# observed rows say what it reported having on its last cycle, and they are kept
|
||||
# apart on purpose - the moment a drifted bay's observed state is treated as
|
||||
# correct, enforcement stops meaning anything. Nothing here writes an assignment
|
||||
# except the seed endpoint at the bottom, which a person has to ask for.
|
||||
|
||||
_OBSERVED_MATCHING = 'matching'
|
||||
_OBSERVED_MISSING = 'missing'
|
||||
_OBSERVED_EXTRA = 'extra'
|
||||
_OBSERVED_DRIFTED = 'drifted'
|
||||
_OBSERVED_UNKNOWN = 'unknown'
|
||||
|
||||
_OBSERVED_CLASSIFICATIONS = (_OBSERVED_MATCHING, _OBSERVED_MISSING,
|
||||
_OBSERVED_EXTRA, _OBSERVED_DRIFTED,
|
||||
_OBSERVED_UNKNOWN)
|
||||
|
||||
|
||||
def _observed_rows(hostname):
|
||||
"""The rows of a host's last report, or [].
|
||||
|
||||
Case-folded with the same short-name and FQDN fallbacks as
|
||||
_computer_by_hostname: the reporter sends its own COMPUTERNAME, and whether
|
||||
that is short or fully qualified is a matter of how the site enrolls PCs,
|
||||
not of which host it is.
|
||||
|
||||
Ordered by queue name, not by insertion: a report replaces the previous one
|
||||
in one pass, so row order carries no meaning, and a stable sort keeps two
|
||||
reads of the same report - and the seed built from it - identical.
|
||||
"""
|
||||
name = (hostname or '').strip().lower()
|
||||
if not name:
|
||||
return []
|
||||
|
||||
query = PrinterObservedQueue.query.order_by(PrinterObservedQueue.queuename)
|
||||
|
||||
rows = query.filter(db.func.lower(PrinterObservedQueue.hostname) == name).all()
|
||||
if rows:
|
||||
return rows
|
||||
|
||||
shortname = name.split('.')[0]
|
||||
if shortname != name:
|
||||
rows = query.filter(
|
||||
db.func.lower(PrinterObservedQueue.hostname) == shortname).all()
|
||||
if rows:
|
||||
return rows
|
||||
# Prefix match only for a plain hostname, as in _computer_by_hostname: a
|
||||
# LIKE wildcard arriving in the path segment would pull back another PC.
|
||||
if not re.match(r'^[a-z0-9-]+$', shortname):
|
||||
return []
|
||||
return query.filter(
|
||||
db.func.lower(PrinterObservedQueue.hostname).like(shortname + '.%')).all()
|
||||
|
||||
|
||||
def _printer_match_index():
|
||||
"""Lookup tables for turning an observed queue into a printer asset.
|
||||
|
||||
Built in two queries instead of one lookup per queue, and built in
|
||||
printerid order so first-match is deterministic: two printers can share an
|
||||
address (a multi-queue device, or a Communication row left behind by a
|
||||
swap), and without a fixed order the same report would classify one way on
|
||||
one read and another way on the next.
|
||||
|
||||
'addressof' is what an install would point the port at - hostname first, IP
|
||||
second, the order Set-ShopdbPrinters uses - and 'addressesof' is every
|
||||
address that IS this printer. Drift is judged against the set, not the
|
||||
preferred one: a queue pointed at the printer's IP where the register names
|
||||
its hostname reaches the same device, and calling that drift would light up
|
||||
every correctly installed bay.
|
||||
"""
|
||||
rows = (db.session.query(Printer, Asset)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.order_by(Printer.printerid)
|
||||
.all())
|
||||
|
||||
communications = {}
|
||||
assetids = [printer.assetid for printer, _asset in rows]
|
||||
if assetids:
|
||||
for communication in (Communication.query
|
||||
.filter(Communication.assetid.in_(assetids))
|
||||
.order_by(Communication.communicationid).all()):
|
||||
communications.setdefault(communication.assetid, []).append(communication)
|
||||
|
||||
index = {'byaddress': {}, 'byqueuename': {}, 'printers': {},
|
||||
'addressof': {}, 'addressesof': {}}
|
||||
for printer, asset in rows:
|
||||
index['printers'][printer.assetid] = (printer, asset)
|
||||
|
||||
own = communications.get(printer.assetid, [])
|
||||
primary = next((item for item in own if item.isprimary), None)
|
||||
if primary is None and own:
|
||||
primary = own[0]
|
||||
|
||||
address = (printer.hostname or '').strip()
|
||||
if not address and primary is not None:
|
||||
address = (primary.ipaddress or '').strip()
|
||||
index['addressof'][printer.assetid] = address or None
|
||||
|
||||
# Every address the printer answers on, not just the primary one: a
|
||||
# second NIC still identifies the same device to a port that uses it.
|
||||
addresses = set()
|
||||
for value in [printer.hostname] + [item.ipaddress for item in own]:
|
||||
key = (value or '').strip().lower()
|
||||
if key:
|
||||
addresses.add(key)
|
||||
index['byaddress'].setdefault(key, printer.assetid)
|
||||
index['addressesof'][printer.assetid] = addresses
|
||||
|
||||
for value in (printer.windowsname, printer.sharename,
|
||||
_install_name(printer, asset),
|
||||
asset.assetnumber, asset.name):
|
||||
key = (value or '').strip().lower()
|
||||
if key:
|
||||
index['byqueuename'].setdefault(key, printer.assetid)
|
||||
|
||||
return index
|
||||
|
||||
|
||||
def _match_observed_queue(row, index):
|
||||
"""The printer assetid an observed queue is, or None.
|
||||
|
||||
PORT ADDRESS first. An IP or FQDN names one device and cannot be reused by
|
||||
a differently-named queue on the next bay, so it is the only key worth
|
||||
trusting. The Windows PORT NAME is looked up in the same address table
|
||||
because a standard TCP/IP port created outside the client is named after the
|
||||
host address itself - that is still an exact match on an address ShopDB
|
||||
holds, not a guess at one.
|
||||
|
||||
Queue name second, and nothing after it. An unmatched queue is reported as
|
||||
unknown: a wrong match seeds a wrong assignment, which is worse than no
|
||||
assignment at all.
|
||||
"""
|
||||
for value in (row.portaddress, row.portname):
|
||||
key = (value or '').strip().lower()
|
||||
if key and key in index['byaddress']:
|
||||
return index['byaddress'][key]
|
||||
|
||||
return index['byqueuename'].get((row.queuename or '').strip().lower())
|
||||
|
||||
|
||||
def _assigned_expectations(assignment, index):
|
||||
"""{printer assetid: what an install of it would look like}.
|
||||
|
||||
Keyed off the resolved assignment, so a PC that inherits its bay's printers
|
||||
is compared against the bay's, which is what the client would have
|
||||
installed. An assigned asset that is retired or is not a printer is left out
|
||||
entirely: for-host skips it too, so a bay cannot be missing it.
|
||||
"""
|
||||
expected = {}
|
||||
assignments = assignment.get('assignments') or []
|
||||
if not assignments:
|
||||
return expected
|
||||
|
||||
universaldrivers = (PrinterDriver.query
|
||||
.filter(PrinterDriver.modelnumberid.is_(None),
|
||||
PrinterDriver.isactive == True)
|
||||
.order_by(PrinterDriver.name).all())
|
||||
|
||||
for item in assignments:
|
||||
entry = index['printers'].get(item['assetid'])
|
||||
if entry is None:
|
||||
continue
|
||||
printer, asset = entry
|
||||
driver = _printer_driver(printer, universaldrivers)
|
||||
expected[printer.assetid] = {
|
||||
'printerid': printer.printerid,
|
||||
'printerassetid': printer.assetid,
|
||||
'printername': asset.name or asset.assetnumber,
|
||||
'queuename': _install_name(printer, asset),
|
||||
'portaddress': index['addressof'].get(printer.assetid),
|
||||
'addresses': index['addressesof'].get(printer.assetid) or set(),
|
||||
'drivername': driver.drivername if driver else None,
|
||||
'isdefault': bool(item.get('isdefault')),
|
||||
'inheritedfromassetid': item.get('inheritedfromassetid'),
|
||||
}
|
||||
return expected
|
||||
|
||||
|
||||
def _observed_driftfields(row, want):
|
||||
"""Which installed properties disagree with the assignment.
|
||||
|
||||
Port address and driver name only. A queue NAME that differs is reported -
|
||||
expectedqueuename is in the payload - but is not drift: the port says it is
|
||||
the same device, and a locally renamed queue still prints to it, so renaming
|
||||
it back is a preference rather than a fault.
|
||||
|
||||
The address is judged against every address the printer answers on rather
|
||||
than against the one an install would prefer, because hostname and IP are
|
||||
the same device. Address drift is therefore a queue that carries the
|
||||
printer's NAME while printing somewhere else - the failure that is invisible
|
||||
from the server and obvious to whoever is standing at the machine.
|
||||
|
||||
A queue with no port address (a non-TCP port, or a reporting host too old to
|
||||
read one) cannot be compared on address, so only its driver is judged.
|
||||
Comparing against a blank would report every such queue as drifted and the
|
||||
view would be noise.
|
||||
"""
|
||||
fields = []
|
||||
|
||||
observedaddress = (row.portaddress or '').strip().lower()
|
||||
addresses = want.get('addresses') or set()
|
||||
if observedaddress and addresses and observedaddress not in addresses:
|
||||
fields.append('portaddress')
|
||||
|
||||
observeddriver = (row.drivername or '').strip().lower()
|
||||
wanteddriver = (want['drivername'] or '').strip().lower()
|
||||
if observeddriver and wanteddriver and observeddriver != wanteddriver:
|
||||
fields.append('drivername')
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def _observed_entry(row=None, want=None, classification=None, printer=None,
|
||||
asset=None, driftfields=None):
|
||||
"""One row of the comparison, observed side and assigned side in one shape.
|
||||
|
||||
Both sides in every entry so a reviewer never has to join two lists: a
|
||||
missing printer has no observed half, an unknown queue has no assigned half,
|
||||
and everything in between carries what it has and nulls for what it lacks.
|
||||
"""
|
||||
return {
|
||||
'classification': classification,
|
||||
'queuename': (row.queuename if row is not None
|
||||
else (want or {}).get('queuename')),
|
||||
'drivername': row.drivername if row is not None else None,
|
||||
'portname': row.portname if row is not None else None,
|
||||
'portaddress': row.portaddress if row is not None else None,
|
||||
'isdefault': bool(row.isdefault) if row is not None else False,
|
||||
'isshared': bool(row.isshared) if row is not None else False,
|
||||
'printerid': (printer.printerid if printer is not None
|
||||
else (want or {}).get('printerid')),
|
||||
'printerassetid': (printer.assetid if printer is not None
|
||||
else (want or {}).get('printerassetid')),
|
||||
'printername': ((asset.name or asset.assetnumber) if asset is not None
|
||||
else (want or {}).get('printername')),
|
||||
'expectedqueuename': (want or {}).get('queuename'),
|
||||
'expectedportaddress': (want or {}).get('portaddress'),
|
||||
'expecteddrivername': (want or {}).get('drivername'),
|
||||
'isassigneddefault': bool((want or {}).get('isdefault')),
|
||||
'inheritedfromassetid': (want or {}).get('inheritedfromassetid'),
|
||||
'driftfields': driftfields or [],
|
||||
}
|
||||
|
||||
|
||||
def _observed_comparison(rows, expected, index):
|
||||
"""Every observed queue classified, then the assigned printers nobody saw.
|
||||
|
||||
matching - assigned, present, installed the way the assignment says.
|
||||
drifted - assigned and present, but on another port or another driver.
|
||||
extra - a printer ShopDB knows, installed here without being assigned.
|
||||
missing - assigned, and the host did not report it.
|
||||
unknown - a queue that matches no printer in ShopDB.
|
||||
"""
|
||||
queues = []
|
||||
seen = set()
|
||||
|
||||
for row in rows:
|
||||
printerassetid = _match_observed_queue(row, index)
|
||||
if printerassetid is None:
|
||||
queues.append(_observed_entry(row=row, classification=_OBSERVED_UNKNOWN))
|
||||
continue
|
||||
|
||||
printer, asset = index['printers'][printerassetid]
|
||||
want = expected.get(printerassetid)
|
||||
if want is None:
|
||||
queues.append(_observed_entry(row=row, classification=_OBSERVED_EXTRA,
|
||||
printer=printer, asset=asset))
|
||||
continue
|
||||
|
||||
seen.add(printerassetid)
|
||||
driftfields = _observed_driftfields(row, want)
|
||||
queues.append(_observed_entry(
|
||||
row=row, want=want, printer=printer, asset=asset,
|
||||
driftfields=driftfields,
|
||||
classification=_OBSERVED_DRIFTED if driftfields else _OBSERVED_MATCHING))
|
||||
|
||||
for printerassetid, want in expected.items():
|
||||
if printerassetid not in seen:
|
||||
queues.append(_observed_entry(want=want, classification=_OBSERVED_MISSING))
|
||||
|
||||
return queues
|
||||
|
||||
|
||||
def _seed_candidate(queues):
|
||||
"""The assignment a seed would write, and the queues it would refuse.
|
||||
|
||||
Matched queues only, in the order _observed_rows returns them. The observed
|
||||
default carries over only when it matched a printer: a default outside the
|
||||
set is rejected by the assignment writer anyway, and pointing a bay at a
|
||||
queue it was never told to install fails on the bay with nothing in ShopDB
|
||||
saying why.
|
||||
"""
|
||||
printerassetids = []
|
||||
skipped = []
|
||||
defaultprinterassetid = None
|
||||
|
||||
for entry in queues:
|
||||
if entry['classification'] == _OBSERVED_MISSING:
|
||||
continue
|
||||
if entry['classification'] == _OBSERVED_UNKNOWN:
|
||||
skipped.append({
|
||||
'queuename': entry['queuename'],
|
||||
'drivername': entry['drivername'],
|
||||
'portname': entry['portname'],
|
||||
'portaddress': entry['portaddress'],
|
||||
'isdefault': entry['isdefault'],
|
||||
})
|
||||
continue
|
||||
if entry['printerassetid'] not in printerassetids:
|
||||
printerassetids.append(entry['printerassetid'])
|
||||
if entry['isdefault'] and defaultprinterassetid is None:
|
||||
defaultprinterassetid = entry['printerassetid']
|
||||
|
||||
return {
|
||||
'printerassetids': printerassetids,
|
||||
'defaultprinterassetid': defaultprinterassetid,
|
||||
'skipped': skipped,
|
||||
}
|
||||
|
||||
|
||||
def _observed_summary(queues):
|
||||
counts = {name: 0 for name in _OBSERVED_CLASSIFICATIONS}
|
||||
for entry in queues:
|
||||
counts[entry['classification']] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def _observed_host_block(hostname, asset, index):
|
||||
"""One host's report, classified against what that host would install.
|
||||
|
||||
The assigned side is resolved from the REPORTING PC, not from whatever asset
|
||||
a caller asked about: a PC's own rows shadow the machine's, so a bay with an
|
||||
override is converged when it matches the override. `source` says which of
|
||||
the two the comparison used.
|
||||
"""
|
||||
rows = _observed_rows(hostname)
|
||||
assignment = (resolve_asset_printers(asset) if asset is not None
|
||||
else {'assignments': [], 'source': 'none'})
|
||||
queues = _observed_comparison(rows, _assigned_expectations(assignment, index),
|
||||
index)
|
||||
|
||||
# One report is written in one pass, so every row carries the same stamp.
|
||||
observedat = rows[0].observedat if rows else None
|
||||
|
||||
return {
|
||||
'hostname': hostname,
|
||||
'assetid': asset.assetid if asset is not None else None,
|
||||
'assetnumber': asset.assetnumber if asset is not None else None,
|
||||
'source': assignment['source'],
|
||||
'observedat': observedat.isoformat() if observedat else None,
|
||||
'queues': queues,
|
||||
'summary': _observed_summary(queues),
|
||||
'seedcandidate': _seed_candidate(queues),
|
||||
}
|
||||
|
||||
|
||||
@printers_asset_bp.route('/observed/<hostname>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.view')
|
||||
def observed_for_host(hostname: str):
|
||||
"""What a host last reported, each queue judged against what it is assigned.
|
||||
|
||||
The mirror of for-host: that endpoint says what this bay SHOULD have, this
|
||||
one says what it told us it DOES have, and puts the two side by side. Read
|
||||
only - nothing here changes an assignment, however wrong the bay looks.
|
||||
|
||||
404 only when the hostname means nothing here: no report and no computer. A
|
||||
known PC that has never reported is an empty queue list, and a report from a
|
||||
host with no computer record still comes back - everything on it is extra or
|
||||
unknown, which is exactly the answer a technician needs.
|
||||
|
||||
`seedcandidate` is a preview of what POST
|
||||
/api/printers/assignments/seed-from-observed would write from this report.
|
||||
"""
|
||||
try:
|
||||
found = _computer_by_hostname(hostname)
|
||||
except ImportError:
|
||||
# No computers plugin, so no hostname -> asset resolution and no
|
||||
# assigned side. The report itself is still worth returning.
|
||||
found = None
|
||||
|
||||
rows = _observed_rows(hostname)
|
||||
if not rows and not found:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'No printer report and no computer for hostname {hostname}',
|
||||
http_code=404)
|
||||
|
||||
computer, asset = found if found else (None, None)
|
||||
known = (computer.hostname if computer is not None
|
||||
else (rows[0].hostname if rows else hostname))
|
||||
return success_response(_observed_host_block(known, asset, _printer_match_index()))
|
||||
|
||||
|
||||
@printers_asset_bp.route('/observed/for-asset/<int:asset_id>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.view')
|
||||
def observed_for_asset(asset_id: int):
|
||||
"""The same comparison, reached from an asset page: one block per host.
|
||||
|
||||
Assigned state lives on the MACHINE and observed state is reported by the
|
||||
PCs, so a machine answers with a block for each PC that controls it. Blocks
|
||||
rather than one merged list because a dualpath pair or a part marker
|
||||
legitimately puts two PCs on one machine, and the only actionable thing
|
||||
about drift is which box to walk to.
|
||||
|
||||
An asset nothing reports for - a machine with no PC, or a PC with no
|
||||
computer record - is an empty `hosts` list and a 200. This hangs off the
|
||||
asset page, and on the day it ships most bays have not reported yet.
|
||||
"""
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
if not asset or not asset.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
|
||||
hosts = []
|
||||
seen = set()
|
||||
own = _own_hostname(asset)
|
||||
if own:
|
||||
hosts.append((own, asset))
|
||||
seen.add(own.lower())
|
||||
for hostname, pcasset in _controlling_computers(asset_id):
|
||||
if hostname.lower() not in seen:
|
||||
seen.add(hostname.lower())
|
||||
hosts.append((hostname, pcasset))
|
||||
|
||||
index = _printer_match_index()
|
||||
return success_response({
|
||||
'assetid': asset_id,
|
||||
'assetnumber': asset.assetnumber,
|
||||
'hosts': [_observed_host_block(hostname, pcasset, index)
|
||||
for hostname, pcasset in hosts],
|
||||
})
|
||||
|
||||
|
||||
def _controlling_computers(assetid):
|
||||
"""(hostname, PC asset) for the active PCs that control this asset.
|
||||
|
||||
Observed state is reported by the PC and the assignment belongs to the
|
||||
machine, so both the comparison and a seed onto a machine have to cross the
|
||||
controls edge. Read incoming here (PC -> machine) because the machine is the
|
||||
asset being asked about, which is the same edge resolve_asset_printers walks
|
||||
outgoing. Oldest edge first, so two PCs on one machine list in a fixed
|
||||
order.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import Computer
|
||||
except ImportError:
|
||||
return []
|
||||
|
||||
typeids = _relationship_typeids(_CONTROLS)[_CONTROLS]
|
||||
if not typeids:
|
||||
return []
|
||||
|
||||
rows = (db.session.query(Computer.hostname, Asset)
|
||||
.select_from(AssetRelationship)
|
||||
.join(Computer, Computer.assetid == AssetRelationship.sourceassetid)
|
||||
.join(Asset, Asset.assetid == Computer.assetid)
|
||||
.filter(AssetRelationship.targetassetid == assetid,
|
||||
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||
AssetRelationship.isactive == True,
|
||||
Asset.isactive == True)
|
||||
.order_by(AssetRelationship.relationshipid)
|
||||
.all())
|
||||
|
||||
controllers = []
|
||||
seen = set()
|
||||
for hostname, pcasset in rows:
|
||||
key = (hostname or '').strip().lower()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
controllers.append((hostname, pcasset))
|
||||
return controllers
|
||||
|
||||
|
||||
def _own_hostname(asset):
|
||||
"""The asset's own hostname when it is a PC, else None."""
|
||||
try:
|
||||
from plugins.computers.models import Computer
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
computer = Computer.query.filter_by(assetid=asset.assetid).first()
|
||||
if computer is None or not computer.hostname:
|
||||
return None
|
||||
return computer.hostname
|
||||
|
||||
|
||||
def _seed_source_hostnames(asset):
|
||||
"""Hosts whose report could seed this asset: its own, then its controllers.
|
||||
|
||||
Its own first because seeding a PC from a different PC's report is never
|
||||
what was meant; the controllers because the normal target is the MACHINE,
|
||||
which reports nothing itself.
|
||||
"""
|
||||
hostnames = []
|
||||
own = _own_hostname(asset)
|
||||
if own:
|
||||
hostnames.append(own)
|
||||
|
||||
for hostname, _pcasset in _controlling_computers(asset.assetid):
|
||||
if hostname.lower() not in [name.lower() for name in hostnames]:
|
||||
hostnames.append(hostname)
|
||||
return hostnames
|
||||
|
||||
|
||||
@printers_asset_bp.route('/assignments/seed-from-observed/<int:asset_id>',
|
||||
methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('printers.edit')
|
||||
def seed_assignment_from_observed(asset_id: int):
|
||||
"""Write what a PC observed as the assignment of the asset in the path.
|
||||
|
||||
THE ONE PATH FROM OBSERVED TO ASSIGNED, and a person has to ask for it. No
|
||||
collector, no cycle and no background job reaches this route: a bay that
|
||||
installed the wrong printer must never be able to make itself right by
|
||||
reporting it. The reviewer reads the comparison (GET
|
||||
/api/printers/observed/<hostname>, or /observed/for-asset/<id> from an asset
|
||||
page), agrees with it, and posts here.
|
||||
|
||||
Normally posted against the MACHINE, so the assignment survives a reimage
|
||||
and follows the bay (see resolve_asset_printers); posting it against the PC
|
||||
works and is warned about, because the PC's own rows then shadow the
|
||||
machine's for good.
|
||||
|
||||
Body, all optional:
|
||||
{"hostname": "PC01", which report to seed from. Omitted, the asset's
|
||||
own hostname or its single controlling PC.
|
||||
"allowunmatched": false} proceed when some queue matches no printer.
|
||||
|
||||
Refuses rather than guesses:
|
||||
409 when more than one controlling PC has reported - which bay is right is
|
||||
not something this endpoint can know.
|
||||
409 when any queue matches no printer, listing every one of them, unless
|
||||
allowunmatched says to seed the rest anyway.
|
||||
400 when nothing matched, because writing the empty set would silently
|
||||
unassign the asset.
|
||||
Nothing is written on any of those; the assignment is left exactly as found.
|
||||
"""
|
||||
asset = db.session.get(Asset, asset_id)
|
||||
if not asset or not asset.isactive:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404)
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
hostname = (data.get('hostname') or '').strip()
|
||||
allowunmatched = bool(data.get('allowunmatched'))
|
||||
|
||||
if hostname:
|
||||
rows = _observed_rows(hostname)
|
||||
if not rows:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
f'No printer report from hostname {hostname}',
|
||||
http_code=404)
|
||||
else:
|
||||
reporting = [name for name in _seed_source_hostnames(asset)
|
||||
if _observed_rows(name)]
|
||||
if not reporting:
|
||||
return error_response(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'No host has reported printers for this asset - name one with '
|
||||
'{"hostname": "..."}',
|
||||
http_code=404)
|
||||
if len(reporting) > 1:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'Several hosts report printers for this asset - name the one to '
|
||||
'seed from',
|
||||
details={'hostnames': reporting},
|
||||
http_code=409)
|
||||
hostname = reporting[0]
|
||||
rows = _observed_rows(hostname)
|
||||
|
||||
index = _printer_match_index()
|
||||
# Classified with no assigned side: seeding asks what each queue IS, not
|
||||
# whether the asset already has it. The reconcile below is the whole set.
|
||||
queues = _observed_comparison(rows, {}, index)
|
||||
candidate = _seed_candidate(queues)
|
||||
|
||||
if candidate['skipped'] and not allowunmatched:
|
||||
return error_response(
|
||||
ErrorCodes.CONFLICT,
|
||||
'{0} observed queue(s) match no printer in ShopDB. Add them as '
|
||||
'printer assets, or repost with allowunmatched to seed the '
|
||||
'rest.'.format(len(candidate['skipped'])),
|
||||
details={
|
||||
'hostname': hostname,
|
||||
'skipped': candidate['skipped'],
|
||||
'printerassetids': candidate['printerassetids'],
|
||||
},
|
||||
http_code=409)
|
||||
|
||||
if not candidate['printerassetids']:
|
||||
return error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
f'Nothing to seed: no queue reported by {hostname} matches a printer '
|
||||
'in ShopDB',
|
||||
details={'hostname': hostname, 'skipped': candidate['skipped']})
|
||||
|
||||
typeids = _relationship_typeids(_USES_PRINTER, _DEFAULT_PRINTER, _CONTROLS)
|
||||
if not typeids[_USES_PRINTER] or not typeids[_DEFAULT_PRINTER]:
|
||||
return error_response(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Relationship types are not seeded - run: flask seed reference-data',
|
||||
http_code=500)
|
||||
|
||||
warnings = []
|
||||
observeddefault = next((entry for entry in queues if entry['isdefault']), None)
|
||||
if observeddefault is not None and candidate['defaultprinterassetid'] is None:
|
||||
warnings.append(
|
||||
'Default queue "{0}" matches no printer in ShopDB; no default '
|
||||
'assigned'.format(observeddefault['queuename']))
|
||||
elif observeddefault is None:
|
||||
warnings.append(f'{hostname} reported no default printer; no default assigned')
|
||||
if candidate['skipped']:
|
||||
warnings.append('{0} unmatched queue(s) were not assigned'.format(
|
||||
len(candidate['skipped'])))
|
||||
if _outgoing_rows(asset_id, typeids[_CONTROLS]):
|
||||
# Own rows shadow rather than merge, so seeding the PC of a bay quietly
|
||||
# takes that bay off the machine's assignment for good.
|
||||
warnings.append(
|
||||
'This asset controls another asset: its own printers now shadow the '
|
||||
'assignment of the machine it controls')
|
||||
|
||||
_reconcile_edges(asset_id, typeids[_USES_PRINTER][0],
|
||||
typeids[_USES_PRINTER], candidate['printerassetids'])
|
||||
_reconcile_edges(asset_id, typeids[_DEFAULT_PRINTER][0],
|
||||
typeids[_DEFAULT_PRINTER],
|
||||
[candidate['defaultprinterassetid']]
|
||||
if candidate['defaultprinterassetid'] is not None else [])
|
||||
db.session.commit()
|
||||
|
||||
printerassetids, defaultassetid = _own_assignment(asset_id, typeids)
|
||||
logger.info('Seeded printer assignment for asset %s from %s: %d printer(s), '
|
||||
'%d skipped', asset_id, hostname, len(printerassetids),
|
||||
len(candidate['skipped']))
|
||||
return success_response({
|
||||
'assetid': asset_id,
|
||||
'seededfromhostname': hostname,
|
||||
'printerassetids': printerassetids,
|
||||
'defaultprinterassetid': defaultassetid,
|
||||
'skipped': candidate['skipped'],
|
||||
'warnings': warnings,
|
||||
}, message='Printer assignment seeded from observed state')
|
||||
|
||||
|
||||
@printers_asset_bp.route('/<int:printer_id>', methods=['GET'])
|
||||
@jwt_required(optional=True)
|
||||
def get_printer(printer_id: int):
|
||||
|
||||
314
plugins/printers/client/Report-PrintersToShopDB.ps1
Normal file
314
plugins/printers/client/Report-PrintersToShopDB.ps1
Normal file
@@ -0,0 +1,314 @@
|
||||
# Report-PrintersToShopDB.ps1
|
||||
#
|
||||
# Reports the print queues this PC ACTUALLY has to ShopDB, so the register can
|
||||
# be compared against what the bay is SUPPOSED to have. ShopDB knows the
|
||||
# assignment (GET /api/printers/for-host/<hostname>, applied by
|
||||
# Set-ShopdbPrinters.ps1); it has never known what is really installed. This is
|
||||
# that missing half.
|
||||
#
|
||||
# TARGET: the ADR-006 collector API.
|
||||
# POST <shopdb>/api/collector/printers
|
||||
# The server is NOT baked in. It comes from HKLM:\SOFTWARE\GE\ShopDB BaseUrl,
|
||||
# which Install-GEEnforce.ps1 provisions and the enforcement client already
|
||||
# needs, or from -ApiUrl in the manifest entry's Args. ADR-015: a site name in
|
||||
# product code is a defect, and this script ships to every site.
|
||||
#
|
||||
# READ ONLY. It calls nothing that creates, changes or removes a queue, a port
|
||||
# or a driver - only Get-*. Convergence is Set-ShopdbPrinters.ps1's job and
|
||||
# stays there; a reporter that also fixes things cannot be trusted to tell you
|
||||
# what was broken.
|
||||
#
|
||||
# OBSERVED IS NOT ASSIGNED. The server stores this in its own table and never
|
||||
# turns it into an assignment on its own. A drifted bay reporting its drift must
|
||||
# not be able to redefine what correct means.
|
||||
#
|
||||
# THE LATEST REPORT REPLACES THE PREVIOUS ONE for this hostname, which makes an
|
||||
# empty queues list a legitimate "this bay has no printers" and wipes the host's
|
||||
# observed rows. So a FAILED enumeration must send NOTHING rather than an empty
|
||||
# list - see the $enumerated flag below. Reporting nothing loses one cycle;
|
||||
# reporting [] after a WMI hiccup deletes real state and reads as a bay that
|
||||
# lost its printers.
|
||||
#
|
||||
# AUTH: the collector API does NOT honor the GE-Enforce IP allowlist (that only
|
||||
# covers the geenforce fetch/report endpoints). It needs a collector.ingest key,
|
||||
# sent as the X-API-Key header. The key is read from HKLM:\SOFTWARE\GE\ShopDB
|
||||
# CollectorKey (the same secret store Report-AssetToShopDB.ps1 uses; provisioned
|
||||
# at imaging), or overridden via the manifest entry's Args -ApiKey. Never bake
|
||||
# the key into the manifest JSON on the share.
|
||||
#
|
||||
# Runs every GE-Enforce cycle as a Type=PS1 / DetectionMethod=Always entry under
|
||||
# the SYSTEM task. Always exits 0 so a printer problem never fails an
|
||||
# enforcement run; failures are logged, never thrown.
|
||||
|
||||
param(
|
||||
# Flask collector endpoint for the printers plugin. Empty resolves from
|
||||
# HKLM:\SOFTWARE\GE\ShopDB BaseUrl; override here if the path ever moves.
|
||||
[string]$ApiUrl = '',
|
||||
|
||||
# collector.ingest key (X-API-Key). Default: read from the GE-Enforce secret
|
||||
# store in the registry. Override with -ApiKey via Args for testing.
|
||||
[string]$ApiKey = '',
|
||||
|
||||
# Identity field of the payload. Defaults to this machine's name, which is
|
||||
# what the assignment side (for-host) and the computers collector both key on.
|
||||
[string]$Hostname = $env:COMPUTERNAME,
|
||||
|
||||
[int]$TimeoutSec = 30,
|
||||
|
||||
# Enumerate and log the payload, post nothing. For proving what a bay would
|
||||
# report before a site is pointed at a live server.
|
||||
[switch]$WhatIfOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# Force TLS 1.2 - older images default to SystemDefault which may negotiate a
|
||||
# protocol the site rejects; the collector POST is HTTPS.
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
$logDir = 'C:\Logs\Shopfloor'
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
||||
}
|
||||
$logFile = Join-Path $logDir ('report-printers-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
|
||||
|
||||
function Log([string]$msg) {
|
||||
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
$REGPATHS = @('HKLM:\SOFTWARE\GE\ShopDB', 'HKLM:\SOFTWARE\WOW6432Node\GE\ShopDB')
|
||||
|
||||
function Get-ShopdbRegValue([string]$name) {
|
||||
foreach ($path in $REGPATHS) {
|
||||
try {
|
||||
if (Test-Path $path) {
|
||||
$value = [string](Get-ItemProperty -Path $path -Name $name -ErrorAction Stop).$name
|
||||
if ($value -and $value.Trim()) { return $value.Trim() }
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
Log "=== Report printers to ShopDB (collector) : $Hostname ==="
|
||||
|
||||
# Server from the GE-Enforce config hive when not passed via Args. Any site
|
||||
# running this script is running the enforcement client, which cannot work
|
||||
# without BaseUrl, so it is present wherever this is deployed.
|
||||
if (-not $ApiUrl) {
|
||||
$base = Get-ShopdbRegValue 'BaseUrl'
|
||||
if ($base) { $ApiUrl = $base.TrimEnd('/') + '/api/collector/printers' }
|
||||
}
|
||||
if (-not $ApiUrl -and -not $WhatIfOnly) {
|
||||
Log 'ERROR no ShopDB URL (HKLM:\SOFTWARE\GE\ShopDB BaseUrl or -ApiUrl). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# collector key from the GE-Enforce secret store when not passed via Args.
|
||||
if (-not $ApiKey) { $ApiKey = Get-ShopdbRegValue 'CollectorKey' }
|
||||
if (-not $ApiKey -and -not $WhatIfOnly) {
|
||||
Log 'ERROR no collector key (HKLM:\SOFTWARE\GE\ShopDB CollectorKey or -ApiKey). Skipping.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Queues that are not devices: the Windows-supplied virtual printers plus the
|
||||
# Office writers. They exist on every image, match nothing in the register, and
|
||||
# would each land as an UNKNOWN row on every bay in the fleet.
|
||||
#
|
||||
# Matched on the PORT, not the queue name, because the name is whatever a user
|
||||
# renamed it to while the port of a virtual device is fixed. Two of them are
|
||||
# matched on driver as well, since a redirected-port queue can share PORTPROMPT.
|
||||
$VIRTUALPORTS = @('PORTPROMPT:', 'SHRFAX:', 'XPSPort:', 'nul:', 'NUL:')
|
||||
$VIRTUALDRIVERS = @(
|
||||
'Microsoft XPS Document Writer',
|
||||
'Microsoft XPS Document Writer v4',
|
||||
'Microsoft Print To PDF',
|
||||
'Microsoft Shared Fax Driver',
|
||||
'Send to Microsoft OneNote Driver',
|
||||
'Microsoft Software Printer Driver'
|
||||
)
|
||||
|
||||
function Test-VirtualQueue([string]$portname, [string]$drivername) {
|
||||
foreach ($p in $VIRTUALPORTS) {
|
||||
if ($portname -and $portname.Trim().ToLower() -eq $p.ToLower()) { return $true }
|
||||
}
|
||||
# OneNote's port is a per-install GUID path, so it can only be caught here.
|
||||
if ($portname -and $portname -like 'Microsoft.Office.OneNote*') { return $true }
|
||||
foreach ($d in $VIRTUALDRIVERS) {
|
||||
if ($drivername -and $drivername.Trim().ToLower() -eq $d.ToLower()) { return $true }
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
# Port address is the primary match key server-side: an IP or FQDN is
|
||||
# unambiguous where a queue name is a local habit. Built once as a lookup so a
|
||||
# bay with 8 queues does not re-enumerate ports 8 times.
|
||||
#
|
||||
# A port with no host address (USB, WSD, a redirected port) reports a null
|
||||
# address and matches on name alone, which is correct: a locally attached
|
||||
# printer is still a real printer worth seeing.
|
||||
$portAddresses = @{}
|
||||
$portsRead = $false
|
||||
try {
|
||||
foreach ($port in (Get-PrinterPort -ErrorAction Stop)) {
|
||||
$address = ''
|
||||
if ($port.PSObject.Properties['PrinterHostAddress']) {
|
||||
$address = [string]$port.PrinterHostAddress
|
||||
}
|
||||
if ($port.Name) { $portAddresses[[string]$port.Name] = $address.Trim() }
|
||||
}
|
||||
$portsRead = $true
|
||||
} catch {
|
||||
Log "WARN Get-PrinterPort failed, falling back to WMI ports: $($_.Exception.Message)"
|
||||
}
|
||||
if (-not $portsRead) {
|
||||
# PS 5.1-era hosts without the PrintManagement module, and images where the
|
||||
# spooler cmdlets are broken but WMI still answers.
|
||||
try {
|
||||
foreach ($port in (Get-CimInstance -ClassName Win32_TCPIPPrinterPort -ErrorAction Stop)) {
|
||||
if ($port.Name) { $portAddresses[[string]$port.Name] = ([string]$port.HostAddress).Trim() }
|
||||
}
|
||||
$portsRead = $true
|
||||
} catch {
|
||||
# Not fatal: queues still report, just without an address to match on.
|
||||
Log "WARN could not read printer ports at all: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
# The queues themselves. $enumerated stays false unless a read actually
|
||||
# succeeded, because "no queues" and "could not look" are the same empty list
|
||||
# and the server treats them very differently (see the header).
|
||||
$queues = @()
|
||||
$enumerated = $false
|
||||
try {
|
||||
foreach ($printer in (Get-Printer -ErrorAction Stop)) {
|
||||
$queues += [pscustomobject]@{
|
||||
queuename = [string]$printer.Name
|
||||
drivername = [string]$printer.DriverName
|
||||
portname = [string]$printer.PortName
|
||||
}
|
||||
}
|
||||
$enumerated = $true
|
||||
} catch {
|
||||
Log "WARN Get-Printer failed, falling back to WMI queues: $($_.Exception.Message)"
|
||||
}
|
||||
if (-not $enumerated) {
|
||||
try {
|
||||
foreach ($printer in (Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop)) {
|
||||
$queues += [pscustomobject]@{
|
||||
queuename = [string]$printer.Name
|
||||
drivername = [string]$printer.DriverName
|
||||
portname = [string]$printer.PortName
|
||||
}
|
||||
}
|
||||
$enumerated = $true
|
||||
} catch {
|
||||
Log "ERROR could not enumerate printers: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $enumerated) {
|
||||
# Deliberately posts nothing. An empty report REPLACES this host's observed
|
||||
# rows, so a failed read must not be able to claim the bay has no printers.
|
||||
Log 'ERROR enumeration failed; posting NOTHING so the last good report stands.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Which queue the interactive user actually prints to. This process is SYSTEM,
|
||||
# and the default printer is per user, so Win32_Printer.Default here describes
|
||||
# the SYSTEM session and is usually wrong. Read the console user's own value
|
||||
# first: HKU\<sid>\...\Windows Device holds "<queue>,winspool,<port>".
|
||||
$defaultName = ''
|
||||
try {
|
||||
$consoleUser = [string](Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop).UserName
|
||||
if ($consoleUser) {
|
||||
$sid = (New-Object System.Security.Principal.NTAccount($consoleUser)).Translate(
|
||||
[System.Security.Principal.SecurityIdentifier]).Value
|
||||
$devicePath = "Registry::HKEY_USERS\$sid\Software\Microsoft\Windows NT\CurrentVersion\Windows"
|
||||
$device = [string](Get-ItemProperty -Path $devicePath -Name Device -ErrorAction Stop).Device
|
||||
if ($device) { $defaultName = ($device -split ',')[0].Trim() }
|
||||
if ($defaultName) { Log "default for $consoleUser : $defaultName" }
|
||||
}
|
||||
} catch {
|
||||
# Nobody logged on, a roaming hive not loaded, or a name that will not
|
||||
# translate. Not worth a warning every cycle on an unattended bay.
|
||||
}
|
||||
if (-not $defaultName) {
|
||||
# Falls back to whatever this session sees. Marked in the log because a
|
||||
# SYSTEM-session default is weak evidence and a reviewer should know which
|
||||
# one they are looking at before seeding an assignment from it.
|
||||
try {
|
||||
$sysDefault = Get-CimInstance -ClassName Win32_Printer -ErrorAction Stop |
|
||||
Where-Object { $_.Default } | Select-Object -First 1
|
||||
if ($sysDefault) {
|
||||
$defaultName = [string]$sysDefault.Name
|
||||
Log "default from the SYSTEM session (no console user): $defaultName"
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$reported = @()
|
||||
$skipped = 0
|
||||
foreach ($queue in $queues) {
|
||||
if (-not $queue.queuename) { continue }
|
||||
if (Test-VirtualQueue $queue.portname $queue.drivername) { $skipped++; continue }
|
||||
|
||||
$portAddress = ''
|
||||
if ($queue.portname -and $portAddresses.ContainsKey($queue.portname)) {
|
||||
$portAddress = [string]$portAddresses[$queue.portname]
|
||||
}
|
||||
|
||||
$row = @{
|
||||
queuename = $queue.queuename
|
||||
isdefault = ($defaultName -and $queue.queuename -eq $defaultName)
|
||||
}
|
||||
# Sent only when present: a null is "not known", and an empty string would
|
||||
# read as a driver or a port genuinely named nothing.
|
||||
if ($queue.drivername) { $row['drivername'] = $queue.drivername }
|
||||
if ($queue.portname) { $row['portname'] = $queue.portname }
|
||||
if ($portAddress) { $row['portaddress'] = $portAddress }
|
||||
|
||||
$reported += $row
|
||||
Log ("queue: {0} | driver={1} | port={2} | address={3} | default={4}" -f `
|
||||
$queue.queuename, $queue.drivername, $queue.portname, $portAddress, $row['isdefault'])
|
||||
}
|
||||
|
||||
Log "reporting $($reported.Count) queue(s), $skipped virtual queue(s) skipped"
|
||||
if ($reported.Count -eq 0) {
|
||||
# Legitimate and meaningful: it clears this host's observed rows so the
|
||||
# comparison shows every assigned printer as missing, which is exactly what
|
||||
# a bay with no queues is.
|
||||
Log 'no real queues on this host; reporting an empty set (clears observed state)'
|
||||
}
|
||||
|
||||
# Collector schema fields (lowercase concatenated). hostname is the identity
|
||||
# field. observedat is sent for the record and named to match the declared
|
||||
# collector schema; the server stamps its own and ignores this one.
|
||||
$body = @{
|
||||
hostname = $Hostname
|
||||
queues = @($reported)
|
||||
observedat = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
}
|
||||
|
||||
# Depth 4 covers hostname -> queues -> row -> value; the default of 2 flattens
|
||||
# the rows to type names.
|
||||
$json = $body | ConvertTo-Json -Compress -Depth 4
|
||||
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD POST $ApiUrl $json"
|
||||
exit 0
|
||||
}
|
||||
|
||||
Log ("POST {0} host={1} queues={2}" -f $ApiUrl, $Hostname, $reported.Count)
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $json `
|
||||
-ContentType 'application/json' `
|
||||
-Headers @{ 'X-API-Key' = $ApiKey } `
|
||||
-TimeoutSec $TimeoutSec -ErrorAction Stop
|
||||
Log ("RESPONSE {0}" -f ($response | ConvertTo-Json -Compress -Depth 4))
|
||||
} catch {
|
||||
Log "ERROR POST failed: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -24,6 +24,12 @@
|
||||
# logged-on person, so it records the desired default in HKLM and leaves applying
|
||||
# it to a logon task. Without that, SYSTEM would set a default nobody sees.
|
||||
#
|
||||
# IT ALSO REGISTERS THAT LOGON TASK, and stages a LOCAL copy of
|
||||
# Apply-ShopdbDefaultPrinter.ps1 for it to run. Recording a default that nothing
|
||||
# ever applies was the gap: the queues appeared, the default never moved. The
|
||||
# local copy is not tidiness - the share this script runs from is mounted only
|
||||
# for the enforcement cycle, and the task fires at logon when it is gone.
|
||||
#
|
||||
# Exits 0 always: a printer problem must not fail an enforcement run.
|
||||
|
||||
param(
|
||||
@@ -36,6 +42,26 @@ param(
|
||||
|
||||
[int]$TimeoutSec = 30,
|
||||
|
||||
# Where the per-user logon script is staged. Anywhere is fine as long as it
|
||||
# is on this PC and every user can read it.
|
||||
[string]$LocalScriptDir = (Join-Path ([Environment]::GetFolderPath('CommonApplicationData')) 'ShopDB'),
|
||||
|
||||
[string]$LogonTaskName = 'ShopDB default printer',
|
||||
|
||||
# The task runs as a GROUP, not a person: a shared bay has no one owner and
|
||||
# the default must be applied for whoever logs on. If this name does not
|
||||
# resolve - it is localised on non-English Windows - the well-known SID is
|
||||
# tried instead.
|
||||
[string]$UsersGroup = 'BUILTIN\Users',
|
||||
|
||||
# 0 means at logon only. A shared bay where people pick their own default
|
||||
# can be pulled back on a repeat; a single-user PC should not be, so the
|
||||
# neutral default is the one that does not argue with the user.
|
||||
[int]$RepeatMinutes = 0,
|
||||
|
||||
# For a site that deploys the logon task by GPO instead.
|
||||
[switch]$NoLogonTask,
|
||||
|
||||
# Report what would change and touch nothing.
|
||||
[switch]$WhatIfOnly
|
||||
)
|
||||
@@ -53,6 +79,127 @@ function Log([string]$msg) {
|
||||
"$ts $msg" | Tee-Object -FilePath $logFile -Append | Out-Null
|
||||
}
|
||||
|
||||
# Resolved once, at script scope: $PSScriptRoot is empty when the file is piped
|
||||
# into powershell rather than run by path, and the logon script sits beside this
|
||||
# one.
|
||||
$SCRIPTDIR = $PSScriptRoot
|
||||
if (-not $SCRIPTDIR -and $MyInvocation.MyCommand.Path) {
|
||||
$SCRIPTDIR = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
}
|
||||
|
||||
function Ensure-LogonTask {
|
||||
# Half of this feature is per-user state that SYSTEM cannot write. All SYSTEM
|
||||
# can do is arrange for something to run AS the user later, which is this
|
||||
# task. Nothing else registered it, so the default was recorded every cycle
|
||||
# and applied never.
|
||||
if ($NoLogonTask) {
|
||||
Log 'logon task: skipped (-NoLogonTask)'
|
||||
return
|
||||
}
|
||||
|
||||
$source = ''
|
||||
if ($SCRIPTDIR) { $source = Join-Path $SCRIPTDIR 'Apply-ShopdbDefaultPrinter.ps1' }
|
||||
if (-not $source -or -not (Test-Path $source)) {
|
||||
Log "SKIP logon task: Apply-ShopdbDefaultPrinter.ps1 is not beside this script"
|
||||
return
|
||||
}
|
||||
|
||||
# THE LOCAL COPY IS LOAD-BEARING. This script runs from a share that is
|
||||
# mounted only for the enforcement cycle; the task fires at logon, when the
|
||||
# share is gone. A task pointing at the share never runs and says nothing.
|
||||
$localscript = Join-Path $LocalScriptDir 'Apply-ShopdbDefaultPrinter.ps1'
|
||||
$refreshed = $false
|
||||
try {
|
||||
if (-not (Test-Path $LocalScriptDir)) {
|
||||
# Inherited ACL is what is wanted here: every user can read it, only
|
||||
# admins can write it, so the task cannot be pointed somewhere else.
|
||||
New-Item -ItemType Directory -Path $LocalScriptDir -Force -ErrorAction Stop | Out-Null
|
||||
}
|
||||
$stale = $true
|
||||
if (Test-Path $localscript) {
|
||||
$stale = (Get-FileHash -Path $localscript -Algorithm SHA256).Hash -ne
|
||||
(Get-FileHash -Path $source -Algorithm SHA256).Hash
|
||||
}
|
||||
if ($stale) {
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD stage the logon script at $localscript"
|
||||
} else {
|
||||
Copy-Item -Path $source -Destination $localscript -Force -ErrorAction Stop
|
||||
$refreshed = $true
|
||||
Log "staged the logon script at $localscript"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
# No local copy means no task worth registering - a task pointing at a
|
||||
# file that is not there is worse than no task, because it looks fine.
|
||||
Log "ERROR staging ${localscript}: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$localscript`""
|
||||
|
||||
$task = Get-ScheduledTask -TaskName $LogonTaskName -ErrorAction SilentlyContinue
|
||||
if ($task -and -not $refreshed) {
|
||||
# Re-registering every cycle throws away the task's run history, which
|
||||
# is the only evidence it ever fired. So it is replaced only when it
|
||||
# points somewhere other than the local copy, or has no group principal
|
||||
# - a task left behind running as one person applies one person's
|
||||
# default. Matched on the PATH rather than the whole argument string
|
||||
# because Task Scheduler is free to normalise quoting, and an exact
|
||||
# compare would churn over a difference that changes nothing.
|
||||
$registered = @($task.Actions)[0]
|
||||
$pointslocal = $registered -and $registered.Arguments -and
|
||||
$registered.Arguments.IndexOf($localscript, [StringComparison]::OrdinalIgnoreCase) -ge 0
|
||||
if ($pointslocal -and $task.Principal.GroupId) {
|
||||
Log "logon task present: $LogonTaskName"
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD register the logon task: $LogonTaskName -> $localscript"
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$triggers = @(New-ScheduledTaskTrigger -AtLogOn)
|
||||
if ($RepeatMinutes -gt 0) {
|
||||
$triggers += New-ScheduledTaskTrigger -Once -At (Get-Date) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $RepeatMinutes)
|
||||
}
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
} catch {
|
||||
# An SKU without the ScheduledTasks module is the likely reason. Nothing
|
||||
# to register with, and still not a reason to fail the run.
|
||||
Log "ERROR building the logon task: $($_.Exception.Message)"
|
||||
return
|
||||
}
|
||||
|
||||
# Limited, not Highest: setting your own default printer needs no elevation,
|
||||
# and a task the whole Users group can trigger should not have any.
|
||||
$candidates = @($UsersGroup)
|
||||
if ($UsersGroup -ne 'S-1-5-32-545') { $candidates += 'S-1-5-32-545' }
|
||||
|
||||
$lasterror = 'no principal accepted'
|
||||
foreach ($groupid in $candidates) {
|
||||
try {
|
||||
$principal = New-ScheduledTaskPrincipal -GroupId $groupid -RunLevel Limited -ErrorAction Stop
|
||||
Register-ScheduledTask -TaskName $LogonTaskName -Action $action -Trigger $triggers `
|
||||
-Principal $principal -Settings $settings -Force -ErrorAction Stop | Out-Null
|
||||
Log "registered the logon task: $LogonTaskName as $groupid"
|
||||
return
|
||||
} catch {
|
||||
$lasterror = $_.Exception.Message
|
||||
}
|
||||
}
|
||||
|
||||
# A missing logon task means the default is not applied. It does not mean the
|
||||
# queues are wrong, so it is logged and the run carries on.
|
||||
Log "ERROR registering ${LogonTaskName}: $lasterror"
|
||||
}
|
||||
|
||||
$REGPATH = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
|
||||
if (-not $BaseUrl) {
|
||||
@@ -72,6 +219,11 @@ if (-not $BaseUrl) {
|
||||
|
||||
Log "=== Set printers for $Hostname ==="
|
||||
|
||||
# Before the API call on purpose: the task depends on files on this PC, not on
|
||||
# the server. A bad minute from the API must not leave a bay with no way to apply
|
||||
# the default it was already told about.
|
||||
Ensure-LogonTask
|
||||
|
||||
$url = $BaseUrl.TrimEnd('/') + '/api/printers/for-host/' + [uri]::EscapeDataString($Hostname)
|
||||
try {
|
||||
$response = Invoke-RestMethod -Uri $url -Method Get -TimeoutSec $TimeoutSec
|
||||
@@ -206,7 +358,8 @@ foreach ($printer in $wanted) {
|
||||
}
|
||||
|
||||
# The default is recorded, not applied: this process is SYSTEM and the setting
|
||||
# is per user. Apply-ShopdbDefaultPrinter.ps1 reads it at logon.
|
||||
# is per user. The logon task registered above runs
|
||||
# Apply-ShopdbDefaultPrinter.ps1, which reads this value in the user's context.
|
||||
if ($defaultname) {
|
||||
if ($WhatIfOnly) {
|
||||
Log "WOULD record default: $defaultname"
|
||||
|
||||
85
plugins/printers/migrations/versions/0005_observedqueues.py
Normal file
85
plugins/printers/migrations/versions/0005_observedqueues.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""printers: printerobservedqueues table (what a bay reported it HAS).
|
||||
|
||||
ShopDB already knows what a bay SHOULD have (usesprinter/defaultprinter rows on
|
||||
the machine). This table holds the other half: the queues a PC reported through
|
||||
POST /api/collector/printers. Observed and assigned stay in separate tables on
|
||||
purpose, so observed drift can never be mistaken for desired state.
|
||||
|
||||
One row per observed queue; the latest report for a host replaces all of that
|
||||
host's rows. The unique index on (hostname, queuename) is the guard that turns a
|
||||
half-finished replace into an IntegrityError instead of duplicate queues.
|
||||
|
||||
Explicit ops rather than create_plugin_tables: the helper builds a per-plugin
|
||||
MetaData filtered to the plugin's own tables, so the foreign key to the core
|
||||
assets table cannot resolve at CreateTable-compile time. Same reason the backups
|
||||
baseline spells its ops out.
|
||||
|
||||
Guarded both ways, so a re-run (or a database where db.create_all already built
|
||||
the table) is a no-op.
|
||||
|
||||
Revision ID: printers0005observedqueues
|
||||
Revises: printers0004drivervendor
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'printers0005observedqueues'
|
||||
down_revision = 'printers0004drivervendor'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerobservedqueues' in inspector.get_table_names():
|
||||
return
|
||||
op.create_table(
|
||||
'printerobservedqueues',
|
||||
sa.Column('printerobservedqueueid', sa.Integer(), nullable=False),
|
||||
# Nullable and no index of its own beyond the explicit one below: an
|
||||
# unenrolled bay still gets to report, and hostname is what identifies
|
||||
# the report.
|
||||
sa.Column('assetid', sa.Integer(), nullable=True),
|
||||
sa.Column('hostname', sa.String(length=255), nullable=False),
|
||||
sa.Column('queuename', sa.String(length=255), nullable=False),
|
||||
sa.Column('drivername', sa.String(length=255), nullable=True),
|
||||
sa.Column('portname', sa.String(length=255), nullable=True),
|
||||
sa.Column('portaddress', sa.String(length=255), nullable=True),
|
||||
sa.Column('isdefault', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column('isshared', sa.Boolean(), nullable=False,
|
||||
server_default=sa.false()),
|
||||
sa.Column('observedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False,
|
||||
server_default=sa.true()),
|
||||
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('printerobservedqueueid'),
|
||||
# Doubles as the read index for the hostname filter (leftmost column),
|
||||
# so no separate hostname index is created.
|
||||
sa.UniqueConstraint('hostname', 'queuename',
|
||||
name='uq_printerobservedqueue_host_queue'),
|
||||
)
|
||||
# Port address is the primary match key from an observed queue back to a
|
||||
# printer asset, so every comparison read hits it.
|
||||
op.create_index('idx_printerobservedqueues_portaddress',
|
||||
'printerobservedqueues', ['portaddress'])
|
||||
op.create_index('idx_printerobservedqueues_assetid',
|
||||
'printerobservedqueues', ['assetid'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'printerobservedqueues' not in inspector.get_table_names():
|
||||
return
|
||||
op.drop_index('idx_printerobservedqueues_assetid',
|
||||
table_name='printerobservedqueues')
|
||||
op.drop_index('idx_printerobservedqueues_portaddress',
|
||||
table_name='printerobservedqueues')
|
||||
op.drop_table('printerobservedqueues')
|
||||
@@ -9,6 +9,7 @@ from .model_supply import ( # data-driven model -> toner/drum/waste mapping
|
||||
CAPACITY_TIERS,
|
||||
)
|
||||
from .supply_alert import PrinterSupplyAlert # per-printer toner alert state
|
||||
from .printer_observation import PrinterObservedQueue # observed queues per bay
|
||||
|
||||
__all__ = [
|
||||
'Printer',
|
||||
@@ -16,6 +17,7 @@ __all__ = [
|
||||
'PrinterDriver',
|
||||
'ModelSupply',
|
||||
'PrinterSupplyAlert',
|
||||
'PrinterObservedQueue',
|
||||
'SUPPLY_TYPES',
|
||||
'SUPPLY_COLORS',
|
||||
'CAPACITY_TIERS',
|
||||
|
||||
109
plugins/printers/models/printer_observation.py
Normal file
109
plugins/printers/models/printer_observation.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""What a bay actually HAS: one row per printer queue a PC reported.
|
||||
|
||||
This is the observed half of the printer loop. The assigned half already exists
|
||||
as usesprinter/defaultprinter relationship rows on the machine, and the two are
|
||||
kept apart on purpose: the moment a drifted bay's observed state is allowed to
|
||||
write assignment rows, enforcement stops meaning anything. Nothing in this table
|
||||
is desired state, and no code may promote it to desired state without a person
|
||||
asking for that explicitly.
|
||||
|
||||
Current state, not history. The latest report for a host REPLACES every row that
|
||||
host had before, so "what does this bay have" is a plain filter and never a
|
||||
question about time. An append-only table would grow with every GE-Enforce cycle
|
||||
and answer that question wrong. The audit log already records each ingest, which
|
||||
is where the history lives.
|
||||
|
||||
Rows are keyed by hostname as reported, with assetid as a resolved convenience:
|
||||
a bay can report before anyone creates its computer record, and the report must
|
||||
still land. Matching an observed queue back to a ShopDB printer asset happens at
|
||||
READ time (port address first, then queue name) so a printer added tomorrow
|
||||
matches without the bay re-reporting.
|
||||
|
||||
Replacement is a hard DELETE of the host's rows, not a soft one: the inherited
|
||||
isactive flag is not a soft-delete marker here, because a queue that is gone
|
||||
from the bay is not observed state that has been retired, it is state that was
|
||||
never observed again.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
class PrinterObservedQueue(BaseModel):
|
||||
"""One Windows print queue seen on one reporting PC at one point in time."""
|
||||
|
||||
__tablename__ = 'printerobservedqueues'
|
||||
|
||||
printerobservedqueueid = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
# The reporting PC, resolved at ingest. Nullable because an unenrolled bay
|
||||
# still gets to report, and no backref: core assets must not grow a
|
||||
# dependency on this plugin.
|
||||
assetid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
||||
nullable=True,
|
||||
comment='Reporting PC asset, resolved from hostname at ingest',
|
||||
)
|
||||
asset = db.relationship('Asset', lazy='select', viewonly=True)
|
||||
|
||||
# Authoritative identity of the report, stored as sent. assetid can be null
|
||||
# or can go stale after a rename; hostname is what the replace keys on.
|
||||
hostname = db.Column(
|
||||
db.String(255),
|
||||
nullable=False,
|
||||
comment='Reporting PC hostname, as sent by the collector',
|
||||
)
|
||||
|
||||
queuename = db.Column(
|
||||
db.String(255),
|
||||
nullable=False,
|
||||
comment='Windows printer (queue) name',
|
||||
)
|
||||
drivername = db.Column(
|
||||
db.String(255),
|
||||
comment='Windows driver name, verbatim; comparable to printerdrivers.drivername',
|
||||
)
|
||||
portname = db.Column(
|
||||
db.String(255),
|
||||
comment='Windows port name, freeform',
|
||||
)
|
||||
# Primary match key: an IP or FQDN identifies a device unambiguously, where
|
||||
# a queue name is only ever a convention. Null for non-TCP/IP ports.
|
||||
portaddress = db.Column(
|
||||
db.String(255),
|
||||
comment='Host address the port points at (IP or FQDN)',
|
||||
)
|
||||
|
||||
isdefault = db.Column(
|
||||
db.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment='Was the default queue for the reporting context',
|
||||
)
|
||||
isshared = db.Column(
|
||||
db.Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment='Queue is shared off this PC',
|
||||
)
|
||||
|
||||
# Server-stamped once per report, so every row of one report carries the
|
||||
# same value and "when did this bay last report" needs no aggregate.
|
||||
observedat = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
comment='When the report that produced this row was ingested',
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
# Windows queue names are unique per host, so this doubles as the read
|
||||
# index for the hostname filter and turns a botched partial replace into
|
||||
# an IntegrityError instead of silent duplicate queues.
|
||||
db.UniqueConstraint('hostname', 'queuename',
|
||||
name='uq_printerobservedqueue_host_queue'),
|
||||
db.Index('idx_printerobservedqueues_portaddress', 'portaddress'),
|
||||
db.Index('idx_printerobservedqueues_assetid', 'assetid'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PrinterObservedQueue {self.hostname}:{self.queuename}>"
|
||||
@@ -3,6 +3,8 @@
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
from flask import Flask, Blueprint
|
||||
@@ -12,13 +14,49 @@ from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db, AssetType
|
||||
|
||||
from .models import (
|
||||
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert
|
||||
Printer, PrinterType, ModelSupply, PrinterDriver, PrinterSupplyAlert,
|
||||
PrinterObservedQueue
|
||||
)
|
||||
from .api import printers_asset_bp
|
||||
from .services import ZabbixService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Widths of the text columns on printerobservedqueues. A Windows queue name
|
||||
# tops out well below this, but a report is unattended machine input: one
|
||||
# oversized string must not turn into a 500 the bay retries every cycle.
|
||||
OBSERVEDTEXTLIMIT = 255
|
||||
|
||||
|
||||
def _observed_text(value, fieldname, warnings):
|
||||
"""Trim one reported string to what the column holds, or None if blank."""
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
if len(text) > OBSERVEDTEXTLIMIT:
|
||||
warnings.append('truncated {} longer than {} characters'.format(
|
||||
fieldname, OBSERVEDTEXTLIMIT))
|
||||
text = text[:OBSERVEDTEXTLIMIT]
|
||||
return text
|
||||
|
||||
|
||||
def _observed_bool(value):
|
||||
"""Coerce a reported flag to bool.
|
||||
|
||||
PowerShell's ConvertTo-Json emits real booleans, but hand-built payloads
|
||||
and older clients send 'True'/'true'/1, and a bare truthiness test would
|
||||
read the string 'False' as a default printer.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in ('true', '1', 'yes')
|
||||
return False
|
||||
|
||||
|
||||
class PrintersPlugin(BasePlugin):
|
||||
"""
|
||||
@@ -79,6 +117,7 @@ class PrintersPlugin(BasePlugin):
|
||||
PrinterDriver, # driver links (SMB/HTTP)
|
||||
ModelSupply, # model -> toner/drum/waste part numbers
|
||||
PrinterSupplyAlert, # per-printer toner alert crossing state
|
||||
PrinterObservedQueue, # what a bay reports it ACTUALLY has
|
||||
]
|
||||
|
||||
def get_services(self) -> Dict[str, Type]:
|
||||
@@ -109,11 +148,16 @@ class PrintersPlugin(BasePlugin):
|
||||
logger.info("Printers plugin installed")
|
||||
|
||||
def get_settings_defaults(self) -> List[dict]:
|
||||
"""Low-toner alert settings.
|
||||
"""Low-toner alert settings plus the dashboard threshold.
|
||||
|
||||
The framework seeds these at install, at enable, and on every
|
||||
`flask plugin upgrade-all`, so a key added in a later version reaches a
|
||||
site that installed an earlier one.
|
||||
|
||||
ONE definition only. There were two, and the later one shadowed this
|
||||
list outright: every alert setting below was silently never declared,
|
||||
so the alerts settings page wrote keys the plugin did not own and a new
|
||||
site seeded none of them. Add keys here; do not add a second method.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
@@ -164,6 +208,16 @@ class PrintersPlugin(BasePlugin):
|
||||
'description': 'Toner percent remaining at or below which a '
|
||||
'critical email fires',
|
||||
},
|
||||
{
|
||||
'key': 'printers_dashboardpercent',
|
||||
'value': '5',
|
||||
'valuetype': 'integer',
|
||||
'category': 'printers',
|
||||
'description': 'Supply percentage at or below which a printer '
|
||||
'appears on the dashboard. Tighter than the '
|
||||
'low-supplies report, which is for planning an '
|
||||
'order rather than walking out to change one.',
|
||||
},
|
||||
]
|
||||
|
||||
def _ensure_asset_type(self) -> None:
|
||||
@@ -286,21 +340,6 @@ class PrintersPlugin(BasePlugin):
|
||||
|
||||
return [printerscli]
|
||||
|
||||
def get_settings_defaults(self) -> List[Dict]:
|
||||
"""Settings this plugin owns for the dashboard card."""
|
||||
return [
|
||||
{
|
||||
'key': 'printers_dashboardpercent',
|
||||
'value': '5',
|
||||
'valuetype': 'integer',
|
||||
'category': 'printers',
|
||||
'description': 'Supply percentage at or below which a printer '
|
||||
'appears on the dashboard. Tighter than the '
|
||||
'low-supplies report, which is for planning an '
|
||||
'order rather than walking out to change one.',
|
||||
},
|
||||
]
|
||||
|
||||
def get_dashboard_widgets(self) -> List[Dict]:
|
||||
"""Dashboard card: printers needing a cartridge.
|
||||
|
||||
@@ -379,3 +418,258 @@ class PrintersPlugin(BasePlugin):
|
||||
('printers.edit', 'Edit printers', 'printers'),
|
||||
('printers.delete', 'Delete printers', 'printers'),
|
||||
]
|
||||
|
||||
# ---- ADR-006 collector contract -------------------------------------
|
||||
|
||||
def get_collector_schema(self) -> Optional[dict]:
|
||||
"""What a bay reports it ACTUALLY has (POST /api/collector/printers).
|
||||
|
||||
The observed half of the printer story. ShopDB already knows what a
|
||||
host SHOULD have (/api/printers/for-host); this is what enumerating the
|
||||
host found, kept apart from the assignment so drift stays visible.
|
||||
|
||||
Declaring this schema is what registers the endpoint - the dispatcher
|
||||
in shopdb/core/api/collector.py discovers it, and brings the collector
|
||||
key / managed-token auth and the audit row with it.
|
||||
"""
|
||||
return {
|
||||
'identityfield': 'hostname',
|
||||
'fields': {
|
||||
'type': 'object',
|
||||
'required': ['hostname', 'queues'],
|
||||
'properties': {
|
||||
'hostname': {
|
||||
'type': 'string',
|
||||
'description': 'Reporting PC (COMPUTERNAME or its '
|
||||
'FQDN). The identity of the report: '
|
||||
'the PC asset is resolved from it, but '
|
||||
'the rows are keyed by the name, so an '
|
||||
'unenrolled bay still reports.',
|
||||
},
|
||||
'queues': {
|
||||
'type': 'array',
|
||||
'description': "Every real print queue on the host. "
|
||||
"This REPLACES the host's previous set, "
|
||||
"so an empty array is a valid report "
|
||||
"that clears it. A client whose "
|
||||
"enumeration FAILED must send nothing "
|
||||
"at all - never an empty array.",
|
||||
'items': {
|
||||
'type': 'object',
|
||||
'required': ['queuename'],
|
||||
'properties': {
|
||||
'queuename': {
|
||||
'type': 'string',
|
||||
'description': 'Windows printer name.',
|
||||
},
|
||||
'drivername': {
|
||||
'type': 'string',
|
||||
'description': 'Driver name verbatim, as '
|
||||
'the INF spells it.',
|
||||
},
|
||||
'portname': {
|
||||
'type': 'string',
|
||||
'description': 'Windows port name.',
|
||||
},
|
||||
'portaddress': {
|
||||
'type': 'string',
|
||||
'description': 'PrinterHostAddress of a '
|
||||
'TCP/IP port - an IP or '
|
||||
'FQDN. The primary key for '
|
||||
'matching this queue to a '
|
||||
'printer asset; omit it for '
|
||||
'a non-TCP port.',
|
||||
},
|
||||
'isdefault': {
|
||||
'type': 'boolean',
|
||||
'description': 'True on the one queue that '
|
||||
'is the default printer.',
|
||||
},
|
||||
'isshared': {
|
||||
'type': 'boolean',
|
||||
'description': 'True when the queue is '
|
||||
'shared off this PC.',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'observedat': {
|
||||
'type': 'string',
|
||||
'format': 'date-time',
|
||||
'description': 'Accepted and ignored. The server stamps '
|
||||
'observedat at ingest, so a bay with a '
|
||||
'wrong clock cannot report itself fresh '
|
||||
'or stale.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def apply_collector_payload(self, payload: dict) -> dict:
|
||||
"""Replace one host's observed queue set (ADR-006).
|
||||
|
||||
THIS NEVER WRITES AN ASSIGNMENT. Observed and assigned are separate
|
||||
tables on purpose: the moment a drifted bay's report is allowed to
|
||||
become what that bay is told to install, enforcement stops meaning
|
||||
anything.
|
||||
Seeding an assignment from observed state is a human action through
|
||||
PUT /api/printers/assignments/for-asset/<id>.
|
||||
|
||||
Replace, not append: this is current state, so the latest report is the
|
||||
whole truth for that host. Nothing is matched to a printer asset here
|
||||
either - resolution happens at read time, so a printer added to ShopDB
|
||||
tomorrow matches yesterday's report without the bay reporting again.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
warnings = []
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
|
||||
queues = payload.get('queues')
|
||||
if queues is None:
|
||||
# Absent and empty are NOT the same thing. [] is a host that
|
||||
# genuinely has no queues and clears its rows; a missing key is a
|
||||
# malformed report, and treating it as a wipe would let one client
|
||||
# bug erase the observed state of the fleet host by host.
|
||||
raise ValueError('queues is required; send an empty array for a '
|
||||
'host with no queues')
|
||||
if not isinstance(queues, list):
|
||||
raise ValueError('queues must be an array')
|
||||
|
||||
# One stamp for the whole report, so "when did this bay last report"
|
||||
# reads off any row of it rather than a MAX over the set.
|
||||
observedat = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
# Resolved BEFORE the rows are written: assetid is a convenience for
|
||||
# the read paths, and hostname stays the identity that the replace
|
||||
# keys on, so an unresolved host still records everything it reported.
|
||||
assetid = self._observed_assetid(hostname, warnings)
|
||||
|
||||
# Case-folded on both sides: one script sends COMPUTERNAME uppercase
|
||||
# and another the lowercase FQDN, and MySQL forgives that while SQLite
|
||||
# does not. Uncompared, the replace would leave the other spelling's
|
||||
# rows in place and the host would appear to have every queue twice.
|
||||
#
|
||||
# A bulk delete so the DELETE reaches the database BEFORE the inserts
|
||||
# below: a re-report repeats the same queue names, and the
|
||||
# (hostname, queuename) unique index rejects the new rows if the old
|
||||
# ones are still there. synchronize_session='fetch' costs one select
|
||||
# and keeps the session's identity map honest, so a caller that read
|
||||
# these rows earlier in the same request does not keep deleted ones.
|
||||
# Every spelling of this host, not just the one it sent. The READ path
|
||||
# treats a short name and its FQDN as the same machine, so a delete that
|
||||
# matched only the exact string would leave the other spelling's rows
|
||||
# behind and the host would appear to have every queue twice - the bug
|
||||
# this replace exists to prevent. A PC that enrolls short and later
|
||||
# reports fully qualified is normal, not exotic.
|
||||
shortname = hostname.lower().split('.')[0]
|
||||
predicate = db.or_(
|
||||
db.func.lower(PrinterObservedQueue.hostname) == hostname.lower(),
|
||||
db.func.lower(PrinterObservedQueue.hostname) == shortname)
|
||||
if re.match(r'^[a-z0-9-]+$', shortname):
|
||||
# Prefix match only for a plain name, as the read path does: a
|
||||
# wildcard built from arbitrary input would delete another PC's rows.
|
||||
predicate = db.or_(
|
||||
predicate,
|
||||
db.func.lower(PrinterObservedQueue.hostname).like(shortname + '.%'))
|
||||
|
||||
db.session.query(PrinterObservedQueue).filter(predicate).delete(
|
||||
synchronize_session='fetch')
|
||||
|
||||
seennames = set()
|
||||
defaultqueue = None
|
||||
stored = 0
|
||||
for entry in queues:
|
||||
if not isinstance(entry, dict):
|
||||
warnings.append('ignored a queue entry that was not an object')
|
||||
continue
|
||||
|
||||
queuename = _observed_text(entry.get('queuename'), 'queuename',
|
||||
warnings)
|
||||
if not queuename:
|
||||
warnings.append('ignored a queue with no queuename')
|
||||
continue
|
||||
if queuename.lower() in seennames:
|
||||
# Windows cannot hold two queues of one name on a host, so this
|
||||
# is a doubled line in the report. Dropping it keeps the
|
||||
# (hostname, queuename) unique index from failing the whole
|
||||
# report over one bad row.
|
||||
warnings.append(
|
||||
'ignored duplicate queue {!r}'.format(queuename))
|
||||
continue
|
||||
seennames.add(queuename.lower())
|
||||
|
||||
isdefault = _observed_bool(entry.get('isdefault'))
|
||||
if isdefault and defaultqueue is not None:
|
||||
# A host has exactly one default printer. Two means the client
|
||||
# misread it, and keeping both would leave the seed candidate
|
||||
# picking one at random.
|
||||
warnings.append(
|
||||
'more than one queue reported as default; kept {!r}'.format(
|
||||
defaultqueue))
|
||||
isdefault = False
|
||||
if isdefault:
|
||||
defaultqueue = queuename
|
||||
|
||||
db.session.add(PrinterObservedQueue(
|
||||
hostname=hostname,
|
||||
queuename=queuename,
|
||||
drivername=_observed_text(entry.get('drivername'), 'drivername',
|
||||
warnings),
|
||||
portname=_observed_text(entry.get('portname'), 'portname',
|
||||
warnings),
|
||||
portaddress=_observed_text(entry.get('portaddress'),
|
||||
'portaddress', warnings),
|
||||
isdefault=isdefault,
|
||||
isshared=_observed_bool(entry.get('isshared')),
|
||||
assetid=assetid,
|
||||
observedat=observedat,
|
||||
))
|
||||
stored += 1
|
||||
|
||||
# flush, not commit: the collector dispatcher owns the transaction and
|
||||
# commits after writing its AuditLog row. Committing here would leave
|
||||
# an unaudited report behind if that write then failed.
|
||||
db.session.flush()
|
||||
|
||||
# Always 'updated'. This endpoint replaces observed rows and creates no
|
||||
# asset, so 'created' never applies, and calling an identical re-report
|
||||
# 'noop' would hide that the bay is still checking in.
|
||||
return {
|
||||
'action': 'updated',
|
||||
'assetid': assetid,
|
||||
'warnings': warnings,
|
||||
'extra': {'queuecount': stored},
|
||||
}
|
||||
|
||||
def _observed_assetid(self, hostname, warnings):
|
||||
"""Computer asset this hostname belongs to, or None with a warning.
|
||||
|
||||
Reuses the resolver behind /api/printers/for-host rather than repeating
|
||||
it: if the two ever disagreed, a bay would be compared against the
|
||||
assignment of a different PC than the one it was told to install from.
|
||||
|
||||
An unknown hostname is a WARNING, not an error. A bay reporting before
|
||||
its PC record exists is normal on a new build, the rows are keyed by
|
||||
hostname and resolve the moment that record appears, and a 500 here
|
||||
would make the client retry and log a failure on every cycle forever.
|
||||
"""
|
||||
try:
|
||||
from .api.asset_routes import _computer_by_hostname
|
||||
row = _computer_by_hostname(hostname)
|
||||
except ImportError:
|
||||
# A lean site can run without the computers plugin (ADR-013). The
|
||||
# observed rows are still worth keeping - they just stay unresolved.
|
||||
warnings.append('computers plugin not installed; observed queues '
|
||||
'stored against the hostname only')
|
||||
return None
|
||||
|
||||
if row is None:
|
||||
warnings.append(
|
||||
'hostname {!r} does not match a known PC; observed queues '
|
||||
'stored unresolved'.format(hostname))
|
||||
return None
|
||||
# _computer_by_hostname returns the (Computer, Asset) pair.
|
||||
return row[1].assetid
|
||||
|
||||
@@ -51,6 +51,19 @@ def _upsert(h, path, payload, unique_field, idfield, list_path=None):
|
||||
return None
|
||||
|
||||
|
||||
def _put(h, path, payload):
|
||||
"""PUT through the harness client. The harness wraps post/get only, so the
|
||||
reconcile-style assignment route goes direct with the same import-mode
|
||||
headers. Returns (status, data)."""
|
||||
headers = {'Authorization': f'Bearer {h.secret}', 'X-Import-Mode': 'true'}
|
||||
resp = h.client.put(path, json=payload, headers=headers)
|
||||
body = resp.get_json() or {}
|
||||
data = body.get('data', body)
|
||||
if resp.status_code >= 400:
|
||||
h.errors.append((path, resp.status_code, payload, data))
|
||||
return resp.status_code, data
|
||||
|
||||
|
||||
# --- classic machinetype routing (per the resolved import decisions) ---------
|
||||
# measuringtools are the physical instruments (the CMM/gauge machine types). A
|
||||
# PC that DRIVES one (pctype CMM/Genspect/Keyence/Wax) is still a computer, not a
|
||||
@@ -350,10 +363,25 @@ def stage_printers(h):
|
||||
'modelnumberid': h.ids.get('model', p['modelid']),
|
||||
'locationid': h.ids.get('location', p['machineid']),
|
||||
}
|
||||
status, _ = h.post('/api/printers', payload)
|
||||
status, data = h.post('/api/printers', payload)
|
||||
assetid = None
|
||||
if status in (200, 201):
|
||||
assetid = _id_of(data, 'assetid')
|
||||
made += 1
|
||||
return {'printers': made}
|
||||
elif status == 409:
|
||||
# Re-run: PRN-<printerid> is already an asset. Resolve it anyway -
|
||||
# without this branch a resumed run crosswalks nothing and the
|
||||
# defaultprinters stage silently links nothing.
|
||||
_, rows = h.get(f"/api/printers?assetnumber={payload['assetnumber']}&per_page=5")
|
||||
items = rows.get('items', rows) if isinstance(rows, dict) else rows
|
||||
for row in (items or []):
|
||||
if str(row.get('assetnumber', '')).strip().lower() == \
|
||||
payload['assetnumber'].lower():
|
||||
assetid = _id_of(row, 'assetid')
|
||||
break
|
||||
if assetid:
|
||||
h.ids.put('printer', p['printerid'], assetid)
|
||||
return {'printers': made, 'crosswalked': h.ids.count('printer')}
|
||||
|
||||
|
||||
# classic pctype -> the measuring instrument that PC drives
|
||||
@@ -676,6 +704,77 @@ def stage_relationships(h):
|
||||
return {'relationships': made, 'dropped_unresolved': dropped}
|
||||
|
||||
|
||||
def stage_defaultprinters(h):
|
||||
"""classic machines.printerid -> a printer assignment on the imported asset.
|
||||
|
||||
Classic records exactly one default printer per machine row. It goes through
|
||||
the assignment reconcile route rather than raw relationship posts: the
|
||||
usesprinter + defaultprinter pairing, printer-type validation, the
|
||||
default-must-be-in-the-set rule and the soft-delete/reactivate semantics all
|
||||
live there, and reconciling converges on a re-run instead of duplicating.
|
||||
|
||||
Runs after assets and printers - it resolves both ends through their
|
||||
crosswalks and drops the row when either end did not import. Needs the
|
||||
usesprinter/defaultprinter relationship types seeded (flask seed
|
||||
reference-data): the route writes nothing without them and every row fails.
|
||||
"""
|
||||
# printerid=0 means no printer recorded, not a dangling FK - excluded so it
|
||||
# never lands in the drop count as something someone has to explain.
|
||||
rows = h.source.rows('shopdb_src',
|
||||
'SELECT machineid, printerid FROM machines '
|
||||
'WHERE isactive=1 AND printerid IS NOT NULL AND printerid>0')
|
||||
# Legacy names for the drop report. Most machines point at a retired
|
||||
# placeholder printer, and a bare "dropped 560" reads like data loss.
|
||||
printernames = {row['printerid']: (row['printerwindowsname'] or '').strip()
|
||||
for row in h.source.rows(
|
||||
'shopdb_src',
|
||||
'SELECT printerid, printerwindowsname FROM printers')}
|
||||
|
||||
counts = {'source_rows': len(rows), 'linked': 0, 'already': 0,
|
||||
'dropped_no_asset': 0, 'dropped_printer_not_imported': 0,
|
||||
'failed': 0}
|
||||
dropped_printers = {}
|
||||
for r in rows:
|
||||
assetid = h.ids.get('asset', r['machineid'])
|
||||
if not assetid:
|
||||
# Machine became a Location, was the 9999 placeholder, a duplicate
|
||||
# machinenumber, or a skipped type.
|
||||
counts['dropped_no_asset'] += 1
|
||||
continue
|
||||
printerassetid = h.ids.get('printer', r['printerid'])
|
||||
if not printerassetid:
|
||||
counts['dropped_printer_not_imported'] += 1
|
||||
label = '{0} {1}'.format(r['printerid'],
|
||||
printernames.get(r['printerid'], '?'))
|
||||
dropped_printers[label] = dropped_printers.get(label, 0) + 1
|
||||
continue
|
||||
|
||||
# Read first: the PUT reconciles the WHOLE set, so a blind write would
|
||||
# unassign anything else already on this asset. Matching state is left
|
||||
# alone so a second run is a no-op, not a rewrite.
|
||||
_, current = h.get(f'/api/printers/assignments/for-asset/{assetid}')
|
||||
current = current if isinstance(current, dict) else {}
|
||||
assigned = list(current.get('printerassetids') or [])
|
||||
if (current.get('defaultprinterassetid') == printerassetid
|
||||
and printerassetid in assigned):
|
||||
counts['already'] += 1
|
||||
continue
|
||||
if printerassetid not in assigned:
|
||||
assigned.append(printerassetid)
|
||||
status, _ = _put(h, f'/api/printers/assignments/for-asset/{assetid}',
|
||||
{'printerassetids': assigned,
|
||||
'defaultprinterassetid': printerassetid})
|
||||
if status in (200, 201):
|
||||
counts['linked'] += 1
|
||||
else:
|
||||
counts['failed'] += 1
|
||||
|
||||
if dropped_printers:
|
||||
counts['dropped_printers'] = dict(
|
||||
sorted(dropped_printers.items(), key=lambda item: -item[1])[:10])
|
||||
return counts
|
||||
|
||||
|
||||
def stage_subnets(h):
|
||||
"""Subnets + VLANs. Classic cidr is the suffix only; full CIDR =
|
||||
INET_NTOA(ipstart)+suffix. VLANs are lookup-or-create by number; duplicate
|
||||
@@ -792,6 +891,7 @@ STAGES = {
|
||||
'notifications': stage_notifications,
|
||||
'knowledgebase': stage_knowledgebase,
|
||||
'relationships': stage_relationships,
|
||||
'defaultprinters': stage_defaultprinters,
|
||||
'subnets': stage_subnets,
|
||||
'usb': stage_usb,
|
||||
'verify': stage_verify,
|
||||
@@ -804,7 +904,7 @@ def main():
|
||||
'--stages',
|
||||
default='reference,employees,catalog,assets,locations,printers,'
|
||||
'metrology,communications,applications,warranties,notifications,'
|
||||
'knowledgebase,relationships,subnets,usb,verify',
|
||||
'knowledgebase,relationships,defaultprinters,subnets,usb,verify',
|
||||
help='comma list of stages to run')
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'printedparts': ('printeditems', 'printeditemtransactions',
|
||||
'printeditemfiles'),
|
||||
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
||||
'printersupplyalerts'),
|
||||
'printersupplyalerts', 'printerobservedqueues'),
|
||||
'slides': ('tvslides',),
|
||||
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
|
||||
'warranty': ('warranties', 'warrantyassets'),
|
||||
|
||||
@@ -54,8 +54,10 @@ EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
|
||||
# baseline.
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib'
|
||||
# printers adds the printersupplyalerts crossing-state table on top of its
|
||||
# anchor, then the exact INF driver name Add-PrinterDriver needs.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0004drivervendor'
|
||||
# anchor, then the exact INF driver name Add-PrinterDriver needs, a vendor on
|
||||
# the driver, and finally printerobservedqueues - what a bay reported it HAS,
|
||||
# kept apart from what it was assigned.
|
||||
EXPECTED_HEAD_REVISION['printers'] = 'printers0005observedqueues'
|
||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||
# rename revision on top, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
|
||||
836
tests/test_plugins/test_printer_observations.py
Normal file
836
tests/test_plugins/test_printer_observations.py
Normal file
@@ -0,0 +1,836 @@
|
||||
"""What a bay ACTUALLY has, and how it compares to what it was assigned.
|
||||
|
||||
ShopDB has always known what a host SHOULD have (usesprinter/defaultprinter rows
|
||||
on the machine, read through /api/printers/for-host). This is the other half: a
|
||||
PC reports the queues it really carries to POST /api/collector/printers, and
|
||||
/api/printers/observed/for-asset/<id> puts the two sides next to each other.
|
||||
|
||||
Three settled rules are what these tests exist to defend:
|
||||
|
||||
Observed and assigned stay apart. A collector report never writes an
|
||||
assignment row. The moment a drifted bay's own state is allowed to become
|
||||
what that bay is told to install, enforcement means nothing and every
|
||||
configuration error becomes permanent the next time the PC checks in.
|
||||
|
||||
A report REPLACES that host's rows. This is current state, not history: the
|
||||
latest report is the whole truth for the host, so "what does this bay have"
|
||||
stays a filter and never becomes a question about time.
|
||||
|
||||
An unmatched queue is UNKNOWN, never a guess. A wrong match seeds a wrong
|
||||
assignment, and a wrong assignment is worse than no assignment because the
|
||||
client then installs it on every cycle.
|
||||
|
||||
Two surfaces are exercised:
|
||||
POST /api/collector/printers what the host reports it has
|
||||
GET /api/printers/observed/for-asset/<assetid> observed against assigned
|
||||
|
||||
Seeding has its own route, POST /api/printers/assignments/seed-from-observed/<id>,
|
||||
because a rollout adopts many machines at once and doing that through the editor
|
||||
would be one round trip per bay. It is still not a second WRITE path: it calls the
|
||||
same _reconcile_edges the editor's PUT does, so both are validated identically and
|
||||
an assignment can only be written one way.
|
||||
|
||||
What makes it safe is that it is explicit. Nothing calls it on a schedule, and a
|
||||
queue that resolves to no known printer is refused rather than guessed into an
|
||||
assignment.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from shopdb.core.models import (
|
||||
Asset,
|
||||
AssetRelationship,
|
||||
AssetType,
|
||||
Communication,
|
||||
CommunicationType,
|
||||
Model,
|
||||
RelationshipType,
|
||||
Vendor,
|
||||
)
|
||||
from plugins.printers.models import Printer, PrinterDriver, PrinterObservedQueue
|
||||
|
||||
COLLECT_URL = '/api/collector/printers'
|
||||
OBSERVED_URL = '/api/printers/observed/for-asset/%d'
|
||||
ASSIGN_URL = '/api/printers/assignments/for-asset/%d'
|
||||
HOST_URL = '/api/printers/for-host/%s'
|
||||
|
||||
KEY = 'testcollectorkey'
|
||||
|
||||
BAY_HOST = 'BAYPC01'
|
||||
SECOND_BAY_HOST = 'BAYPC02'
|
||||
OFFICE_HOST = 'OFFICEPC01'
|
||||
|
||||
# Addresses only, no site meaning: the port address is the match key under test.
|
||||
ADDRESS_A = '10.20.0.11'
|
||||
ADDRESS_B = '10.20.0.12'
|
||||
ADDRESS_C = '10.20.0.13'
|
||||
ADDRESS_NOBODY = '10.20.0.99'
|
||||
|
||||
DRIVER_NAME = 'HP Universal Printing PS'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def collector_key(app):
|
||||
"""Set the shared collector key. A site may scope a printers-only key
|
||||
instead (COLLECTOR_API_KEY_PRINTERS); the shared key is the documented
|
||||
fallback and is what the reporter script falls back to as well."""
|
||||
old = app.config.get('COLLECTOR_API_KEY')
|
||||
app.config['COLLECTOR_API_KEY'] = KEY
|
||||
yield KEY
|
||||
app.config['COLLECTOR_API_KEY'] = old
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scene(db):
|
||||
"""Two bay PCs controlling one machine, an office PC controlling nothing,
|
||||
and three printers each reachable at its own address.
|
||||
|
||||
No assignments and no observations: every test builds the pair it needs, so
|
||||
a classification can never be an accident of the fixture.
|
||||
"""
|
||||
from plugins.computers.models import Computer
|
||||
|
||||
pc_type = AssetType(assettype='computer', pluginname='computers',
|
||||
tablename='computers')
|
||||
machine_type = AssetType(assettype='machine', pluginname='machines',
|
||||
tablename='machines')
|
||||
printer_type = AssetType(assettype='printer', pluginname='printers',
|
||||
tablename='printers')
|
||||
uses_type = RelationshipType(relationshiptype='usesprinter',
|
||||
description='Asset to a printer installed on it',
|
||||
isdirectional=True)
|
||||
default_type = RelationshipType(relationshiptype='defaultprinter',
|
||||
description='Asset to its default printer',
|
||||
isdirectional=True)
|
||||
controls_type = RelationshipType(relationshiptype='controls',
|
||||
description='Operational authority',
|
||||
isdirectional=True)
|
||||
ip_comtype = CommunicationType(comtype='IP')
|
||||
db.session.add_all([pc_type, machine_type, printer_type, uses_type,
|
||||
default_type, controls_type, ip_comtype])
|
||||
db.session.flush()
|
||||
|
||||
vendor = Vendor(vendor='HP')
|
||||
db.session.add(vendor)
|
||||
db.session.flush()
|
||||
model = Model(modelnumber='LaserJet M602', vendorid=vendor.vendorid)
|
||||
db.session.add(model)
|
||||
db.session.flush()
|
||||
# Model-bound so the driver ShopDB would install is unambiguous: driver
|
||||
# drift is only meaningful against a driver the assigned side actually names.
|
||||
db.session.add(PrinterDriver(name='HP Universal Print Driver',
|
||||
drivername=DRIVER_NAME,
|
||||
location=r'\\server\share\hp',
|
||||
vendorid=vendor.vendorid,
|
||||
modelnumberid=model.modelnumberid,
|
||||
isactive=True))
|
||||
|
||||
baypc = Asset(assetnumber='1001', name='Bay PC',
|
||||
assettypeid=pc_type.assettypeid, isactive=True)
|
||||
secondbaypc = Asset(assetnumber='1002', name='Second Bay PC',
|
||||
assettypeid=pc_type.assettypeid, isactive=True)
|
||||
officepc = Asset(assetnumber='1003', name='Office PC',
|
||||
assettypeid=pc_type.assettypeid, isactive=True)
|
||||
machine = Asset(assetnumber='2001', name='Lathe',
|
||||
assettypeid=machine_type.assettypeid, isactive=True)
|
||||
db.session.add_all([baypc, secondbaypc, officepc, machine])
|
||||
db.session.flush()
|
||||
|
||||
printers = {}
|
||||
for suffix, name, address in (('A', 'Bay label printer', ADDRESS_A),
|
||||
('B', 'Bay laser printer', ADDRESS_B),
|
||||
('C', 'Office laser printer', ADDRESS_C)):
|
||||
asset = Asset(assetnumber='PRN-%s' % suffix, name=name,
|
||||
assettypeid=printer_type.assettypeid, isactive=True)
|
||||
db.session.add(asset)
|
||||
db.session.flush()
|
||||
printer = Printer(assetid=asset.assetid,
|
||||
windowsname='PRINTER-%s' % suffix,
|
||||
hostname='printer-%s' % suffix.lower(),
|
||||
vendorid=vendor.vendorid,
|
||||
modelnumberid=model.modelnumberid,
|
||||
isnetwork=True)
|
||||
db.session.add(printer)
|
||||
db.session.add(Communication(assetid=asset.assetid,
|
||||
comtypeid=ip_comtype.comtypeid,
|
||||
ipaddress=address, isprimary=True))
|
||||
printers[suffix] = {'asset': asset, 'printer': printer,
|
||||
'address': address}
|
||||
|
||||
db.session.add_all([
|
||||
Computer(assetid=baypc.assetid, hostname=BAY_HOST),
|
||||
Computer(assetid=secondbaypc.assetid, hostname=SECOND_BAY_HOST),
|
||||
Computer(assetid=officepc.assetid, hostname=OFFICE_HOST),
|
||||
])
|
||||
db.session.commit()
|
||||
|
||||
return {
|
||||
'baypc': baypc,
|
||||
'secondbaypc': secondbaypc,
|
||||
'officepc': officepc,
|
||||
'machine': machine,
|
||||
'printers': printers,
|
||||
'uses_type': uses_type,
|
||||
'default_type': default_type,
|
||||
'controls_type': controls_type,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _queue(name, address=None, drivername=DRIVER_NAME, isdefault=False,
|
||||
portname=None):
|
||||
"""One entry of the reported queues array, spelled as the client sends it."""
|
||||
return {
|
||||
'queuename': name,
|
||||
'drivername': drivername,
|
||||
'portname': portname or ('IP_%s' % address if address else 'LPT1'),
|
||||
'portaddress': address,
|
||||
'isdefault': isdefault,
|
||||
}
|
||||
|
||||
|
||||
def _report(client, hostname, queues):
|
||||
return client.post(COLLECT_URL, json={'hostname': hostname,
|
||||
'queues': queues},
|
||||
headers={'X-API-Key': KEY})
|
||||
|
||||
|
||||
def _stored(hostname):
|
||||
"""Observed rows held for a host, read straight from the table.
|
||||
|
||||
Read here rather than through the comparison endpoint because replacement is
|
||||
a property of the STORE: a read that filtered by newest timestamp would hide
|
||||
an append-only table growing behind it.
|
||||
"""
|
||||
return PrinterObservedQueue.query.filter(
|
||||
PrinterObservedQueue.hostname.ilike(hostname)).all()
|
||||
|
||||
|
||||
def _queuenames(hostname):
|
||||
return {row.queuename for row in _stored(hostname)}
|
||||
|
||||
|
||||
def _assignment_rows(scene):
|
||||
"""Every active usesprinter/defaultprinter row in the database.
|
||||
|
||||
Not scoped to one asset on purpose: a collector report must not create an
|
||||
assignment ANYWHERE, including on an asset the test never named.
|
||||
"""
|
||||
typeids = [scene['uses_type'].relationshiptypeid,
|
||||
scene['default_type'].relationshiptypeid]
|
||||
rows = AssetRelationship.query.filter(
|
||||
AssetRelationship.relationshiptypeid.in_(typeids),
|
||||
AssetRelationship.isactive == True).all() # noqa: E712
|
||||
return {(row.sourceassetid, row.targetassetid, row.relationshiptypeid)
|
||||
for row in rows}
|
||||
|
||||
|
||||
def _relate(db, source, target, reltype):
|
||||
db.session.add(AssetRelationship(sourceassetid=source.assetid,
|
||||
targetassetid=target.assetid,
|
||||
relationshiptypeid=reltype.relationshiptypeid))
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def _assign(db, scene, owner, suffixes, default=None):
|
||||
for suffix in suffixes:
|
||||
_relate(db, owner, scene['printers'][suffix]['asset'], scene['uses_type'])
|
||||
if default:
|
||||
_relate(db, owner, scene['printers'][default]['asset'], scene['default_type'])
|
||||
|
||||
|
||||
def _controls(db, scene, pc):
|
||||
_relate(db, pc, scene['machine'], scene['controls_type'])
|
||||
|
||||
|
||||
def _assetid(scene, suffix):
|
||||
return scene['printers'][suffix]['asset'].assetid
|
||||
|
||||
|
||||
def _hostblocks(response):
|
||||
"""{hostname (lowercased): block} out of a comparison payload.
|
||||
|
||||
Normalized in one place because a machine answers with one block per
|
||||
controlling PC while a PC has only itself, and the endpoint may reasonably
|
||||
return the single case unwrapped. The semantics under test are the same
|
||||
either way; which of the two shapes it is, is not.
|
||||
"""
|
||||
payload = response.get_json()['data']
|
||||
blocks = payload.get('hosts')
|
||||
if blocks is None:
|
||||
blocks = [payload]
|
||||
return {(block.get('hostname') or '').lower(): block for block in blocks}
|
||||
|
||||
|
||||
def _oneblock(response, hostname):
|
||||
blocks = _hostblocks(response)
|
||||
assert hostname.lower() in blocks, \
|
||||
'no block for %s in %s' % (hostname, sorted(blocks))
|
||||
return blocks[hostname.lower()]
|
||||
|
||||
|
||||
def _classified(block):
|
||||
"""{classification: {identity}} for one host block.
|
||||
|
||||
Identity is the printer assetid when the row resolved to a printer, and the
|
||||
queue name when it did not - which is exactly the distinction the UNKNOWN
|
||||
rule is about. `missing` rows describe an assigned printer that was never
|
||||
observed, so they may arrive in the queue list or in a list of their own.
|
||||
"""
|
||||
rows = list(block.get('queues') or [])
|
||||
rows.extend(block.get('missing') or [])
|
||||
result = {}
|
||||
for row in rows:
|
||||
assetid = row.get('printerassetid')
|
||||
identity = assetid if assetid is not None else row.get('queuename')
|
||||
result.setdefault(row.get('classification', 'missing'), set()).add(identity)
|
||||
return result
|
||||
|
||||
|
||||
def _seedcandidate(block):
|
||||
seed = block.get('seedcandidate')
|
||||
assert seed is not None, 'block carries no seedcandidate: %s' % sorted(block)
|
||||
return seed
|
||||
|
||||
|
||||
def _skippedtext(seed):
|
||||
"""Everything the seed candidate says it left out, as one lowercase blob.
|
||||
|
||||
The shape of the skip report is not what matters; that an operator can see
|
||||
WHICH queues were not seeded is. A seed that silently drops the queues it
|
||||
could not match looks identical to a bay that has nothing else installed.
|
||||
"""
|
||||
skipped = (seed.get('skippedqueuenames') if 'skippedqueuenames' in seed
|
||||
else seed.get('skipped'))
|
||||
assert skipped is not None, \
|
||||
'seedcandidate reports nothing about what it skipped: %s' % sorted(seed)
|
||||
return json.dumps(skipped).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_a_report_stores_the_hosts_queues(client, db, collector_key, scene):
|
||||
"""The queues a bay reports land verbatim, port address included.
|
||||
|
||||
Port address is the primary match key and the only unambiguous one. If it is
|
||||
dropped or rewritten on the way in, every later comparison falls back to
|
||||
matching on a queue name - a naming convention - and a renamed queue starts
|
||||
reading as a different printer.
|
||||
"""
|
||||
response = _report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('PRINTER-B', ADDRESS_B),
|
||||
])
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
rows = {row.queuename: row for row in _stored(BAY_HOST)}
|
||||
assert set(rows) == {'PRINTER-A', 'PRINTER-B'}
|
||||
assert rows['PRINTER-A'].portaddress == ADDRESS_A
|
||||
assert rows['PRINTER-A'].drivername == DRIVER_NAME
|
||||
assert rows['PRINTER-A'].isdefault is True
|
||||
assert rows['PRINTER-B'].isdefault is False
|
||||
|
||||
|
||||
def test_a_second_report_replaces_the_first(client, db, collector_key, scene):
|
||||
"""The latest report is the whole truth for that host.
|
||||
|
||||
Accumulating instead would grow a row per queue per GE-Enforce cycle forever
|
||||
and, worse, answer "what does this bay have" with every queue it has ever
|
||||
had - so a printer removed from a bay would look installed for the rest of
|
||||
the site's life.
|
||||
"""
|
||||
first = _report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A),
|
||||
_queue('PRINTER-B', ADDRESS_B),
|
||||
])
|
||||
assert first.status_code == 200, first.get_json()
|
||||
|
||||
second = _report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
|
||||
assert second.status_code == 200, second.get_json()
|
||||
|
||||
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
|
||||
|
||||
|
||||
def test_a_report_replaces_only_the_reporting_host(client, db, collector_key, scene):
|
||||
"""One bay's report must not touch another bay's rows.
|
||||
|
||||
Replacement keyed on anything wider than the hostname turns every cycle into
|
||||
a race: whichever PC reported last would be the only one ShopDB believes has
|
||||
any printers at all.
|
||||
"""
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
|
||||
_report(client, SECOND_BAY_HOST, [_queue('PRINTER-B', ADDRESS_B)])
|
||||
|
||||
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
|
||||
assert _queuenames(SECOND_BAY_HOST) == {'PRINTER-B'}
|
||||
|
||||
|
||||
def test_an_empty_queue_list_clears_the_host(client, db, collector_key, scene):
|
||||
"""A host that genuinely has no printers reports that, and it takes effect.
|
||||
|
||||
This is the counterpart of the rule below: [] is a real observation and must
|
||||
wipe the previous set, or a printer removed from a bay stays visible in
|
||||
ShopDB forever.
|
||||
"""
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
|
||||
|
||||
cleared = _report(client, BAY_HOST, [])
|
||||
|
||||
assert cleared.status_code == 200, cleared.get_json()
|
||||
assert _stored(BAY_HOST) == []
|
||||
|
||||
|
||||
def test_a_report_with_no_queues_key_is_rejected_and_changes_nothing(
|
||||
client, db, collector_key, scene):
|
||||
"""Absent is not empty, and the difference is the whole safety margin.
|
||||
|
||||
A client whose enumeration failed must send nothing. If a malformed report
|
||||
with no queues key were treated as "this host has none", one client bug
|
||||
would erase the observed state of the fleet host by host, quietly, at
|
||||
collector cadence.
|
||||
"""
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A)])
|
||||
|
||||
response = client.post(COLLECT_URL, json={'hostname': BAY_HOST},
|
||||
headers={'X-API-Key': KEY})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert _queuenames(BAY_HOST) == {'PRINTER-A'}
|
||||
|
||||
|
||||
def test_an_unknown_hostname_warns_instead_of_failing(client, db, collector_key,
|
||||
scene):
|
||||
"""A bay ShopDB has no PC record for still gets to report.
|
||||
|
||||
Reporting before enrollment is normal on a fresh build, and the rows are
|
||||
keyed by hostname so they resolve the moment the record appears. Failing the
|
||||
call instead would make the client log an error on every cycle on every
|
||||
unenrolled bay, and real failures would drown in it.
|
||||
"""
|
||||
response = _report(client, 'NOSUCHHOST', [_queue('PRINTER-A', ADDRESS_A)])
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
data = response.get_json()['data']
|
||||
assert data['warnings'], 'an unresolvable hostname reported no warning'
|
||||
assert any('nosuchhost' in warning.lower() for warning in data['warnings'])
|
||||
assert _queuenames('NOSUCHHOST') == {'PRINTER-A'}
|
||||
|
||||
|
||||
def test_a_report_never_changes_an_assignment(client, db, collector_key, scene):
|
||||
"""The separation this whole design rests on.
|
||||
|
||||
The bay is assigned printer A and reports B and C instead - the exact drift
|
||||
the feature exists to show. Not one assignment row may move. If observed
|
||||
state could write the assigned side, a misconfigured bay would rewrite its
|
||||
own orders on its next check-in, drift would self-heal into permanence, and
|
||||
/api/printers/for-host would stop meaning "what this host should have".
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
before = _assignment_rows(scene)
|
||||
|
||||
response = _report(client, BAY_HOST, [
|
||||
_queue('PRINTER-B', ADDRESS_B, isdefault=True),
|
||||
_queue('PRINTER-C', ADDRESS_C),
|
||||
])
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
assert _assignment_rows(scene) == before
|
||||
|
||||
# And the host is still told to install exactly what it was told before.
|
||||
resolved = client.get(HOST_URL % BAY_HOST)
|
||||
assert resolved.status_code == 200
|
||||
assigned = resolved.get_json()['data']['printers']
|
||||
assert [row['assetid'] for row in assigned] == [_assetid(scene, 'A')]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_comparison_requires_authentication(client, db, scene):
|
||||
"""Observed state is internal detail, not a machine-readable public feed.
|
||||
|
||||
The collector endpoint has its own key auth for unattended clients; this
|
||||
read is for people, so it goes through the normal login. Left open, a bay's
|
||||
installed-software-adjacent inventory would be readable by anyone who can
|
||||
reach the API.
|
||||
"""
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_comparison_classifies_matching_missing_and_extra(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""The three plain answers, in one bay.
|
||||
|
||||
A is assigned and observed (matching), B is assigned and absent (missing),
|
||||
C is observed and never assigned (extra). Collapsing any of these into the
|
||||
others is what makes a comparison view worthless: missing is a bay that
|
||||
never converged, extra is a printer somebody added by hand, and reading one
|
||||
as the other sends a technician to the wrong problem.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A', 'B'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('PRINTER-C', ADDRESS_C),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
classified = _classified(_oneblock(response, BAY_HOST))
|
||||
assert classified.get('matching') == {_assetid(scene, 'A')}
|
||||
assert classified.get('missing') == {_assetid(scene, 'B')}
|
||||
assert classified.get('extra') == {_assetid(scene, 'C')}
|
||||
|
||||
|
||||
def test_a_queue_pointing_at_the_wrong_address_is_drifted(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""Right printer, wrong port: drifted, not matching.
|
||||
|
||||
The queue carries the assigned printer's name but prints to an address that
|
||||
is not that printer's. Called matching, the bay reads as converged while its
|
||||
jobs come out somewhere else - the failure that is invisible from the server
|
||||
and obvious to whoever is standing at the machine.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_NOBODY, isdefault=True),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
classified = _classified(_oneblock(response, BAY_HOST))
|
||||
assert classified.get('drifted') == {_assetid(scene, 'A')}
|
||||
assert not classified.get('matching')
|
||||
|
||||
|
||||
def test_a_queue_on_the_wrong_driver_is_drifted(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""Right printer, right port, driver nobody assigned: still drifted.
|
||||
|
||||
Drift is the whole reason Set-ShopdbPrinters repairs queues instead of only
|
||||
creating them. A queue left on a driver the register does not name is the
|
||||
case that prints, badly - wrong tray, wrong duplex, wrong paper - so it must
|
||||
not read as converged.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, drivername='Some Other Driver',
|
||||
isdefault=True),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
classified = _classified(_oneblock(response, BAY_HOST))
|
||||
assert classified.get('drifted') == {_assetid(scene, 'A')}
|
||||
assert not classified.get('matching')
|
||||
|
||||
|
||||
def test_port_address_beats_a_colliding_queue_name(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""When the two match keys disagree, the address wins.
|
||||
|
||||
A queue name is a convention a technician typed; an address identifies a
|
||||
device. A bay that named its queue after one printer while pointing it at
|
||||
another is precisely the mistake this view exists to surface, and matching
|
||||
on the name would report the mistake as agreement.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-B', ADDRESS_A, isdefault=True)])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
block = _oneblock(response, BAY_HOST)
|
||||
resolved = {row.get('queuename'): row.get('printerassetid')
|
||||
for row in (block.get('queues') or [])}
|
||||
assert resolved.get('PRINTER-B') == _assetid(scene, 'A')
|
||||
|
||||
|
||||
def test_a_queue_matching_no_printer_is_unknown_not_guessed(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""No match is reported as no match.
|
||||
|
||||
Nothing in ShopDB carries this name or this address. A fuzzy fallback that
|
||||
reached for the nearest printer would put a wrong assetid in front of a
|
||||
reviewer, and that reviewer's next click writes it into an assignment the
|
||||
client then installs on every cycle. Unknown costs one conversation; a wrong
|
||||
match costs a bay.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('Reception Copier', ADDRESS_NOBODY),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
block = _oneblock(response, BAY_HOST)
|
||||
classified = _classified(block)
|
||||
assert classified.get('unknown') == {'Reception Copier'}
|
||||
# Unknown is not a quiet flavour of extra: extra means "resolved to a
|
||||
# printer nobody assigned", which is a different conversation.
|
||||
assert 'Reception Copier' not in classified.get('extra', set())
|
||||
unmatched = next(row for row in block['queues']
|
||||
if row.get('queuename') == 'Reception Copier')
|
||||
assert unmatched.get('printerassetid') is None
|
||||
|
||||
|
||||
def test_a_machine_answers_per_controlling_host(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""The assignment lives on the machine; the observations live on the PCs.
|
||||
|
||||
A dualpath pair or a part marker legitimately puts two PCs on one machine.
|
||||
Merging their queues into one list would hide WHICH bay drifted, and the
|
||||
only actionable thing about drift is which box to walk to.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_controls(db, scene, scene['secondbaypc'])
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
|
||||
_report(client, SECOND_BAY_HOST, [_queue('PRINTER-C', ADDRESS_C)])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['machine'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
blocks = _hostblocks(response)
|
||||
assert {BAY_HOST.lower(), SECOND_BAY_HOST.lower()} <= set(blocks)
|
||||
converged = _classified(blocks[BAY_HOST.lower()])
|
||||
drifted = _classified(blocks[SECOND_BAY_HOST.lower()])
|
||||
assert converged.get('matching') == {_assetid(scene, 'A')}
|
||||
assert drifted.get('missing') == {_assetid(scene, 'A')}
|
||||
assert drifted.get('extra') == {_assetid(scene, 'C')}
|
||||
|
||||
|
||||
def test_comparison_reports_when_the_host_last_reported(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""A block with no timestamp cannot be trusted.
|
||||
|
||||
Observed state is only ever as good as its age: a bay that stopped reporting
|
||||
six months ago and a bay that reported this morning produce identical
|
||||
comparisons, and only the timestamp tells a reviewer which one is worth
|
||||
acting on.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
block = _oneblock(response, BAY_HOST)
|
||||
# Server-stamped at ingest, so a bay with a wrong clock cannot report itself
|
||||
# fresh. Either spelling of the key is the ingest stamp.
|
||||
stamp = block.get('reportedat') or block.get('observedat')
|
||||
assert stamp, 'no report timestamp on the host block: %s' % sorted(block)
|
||||
|
||||
|
||||
def test_a_host_that_has_never_reported_is_empty_not_an_error(
|
||||
client, db, scene, auth_headers):
|
||||
"""Silence is a legitimate answer.
|
||||
|
||||
Most PCs will not have reported yet the day this ships. A 404 or a 500 here
|
||||
would break the asset page for every one of them, and the page is where the
|
||||
assignment is edited.
|
||||
"""
|
||||
_assign(db, scene, scene['machine'], ['A'], default='A')
|
||||
_controls(db, scene, scene['baypc'])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
classified = _classified(_oneblock(response, BAY_HOST))
|
||||
# Everything assigned is missing, and nothing was observed.
|
||||
assert classified.get('missing') == {_assetid(scene, 'A')}
|
||||
assert not classified.get('matching')
|
||||
assert not classified.get('extra')
|
||||
assert not classified.get('unknown')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Seeding an assignment from what was observed
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_seedcandidate_offers_matched_queues_and_names_what_it_skipped(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""The seed is a proposal made of matches only, and it says what it left out.
|
||||
|
||||
Unknown queues are never seeded - that is the never-guess rule reaching the
|
||||
write path. But dropping them silently is its own failure: the reviewer sees
|
||||
a short list, assumes the bay only has those, and the real queue goes
|
||||
unrecorded with nothing anywhere saying it was skipped.
|
||||
"""
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('Reception Copier', ADDRESS_NOBODY),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
seed = _seedcandidate(_oneblock(response, BAY_HOST))
|
||||
assert list(seed['printerassetids']) == [_assetid(scene, 'A')]
|
||||
assert 'reception copier' in _skippedtext(seed)
|
||||
|
||||
|
||||
def test_seedcandidate_leaves_the_default_unset_when_it_cannot_be_matched(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""An unmatched default seeds no default at all.
|
||||
|
||||
The alternative is picking one of the matched queues so the field is not
|
||||
blank, which would change a user's default printer on the strength of a
|
||||
guess. No default is a state the client already handles quietly; a wrong one
|
||||
is a support call.
|
||||
"""
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A),
|
||||
_queue('Reception Copier', ADDRESS_NOBODY, isdefault=True),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
seed = _seedcandidate(_oneblock(response, BAY_HOST))
|
||||
assert list(seed['printerassetids']) == [_assetid(scene, 'A')]
|
||||
assert seed['defaultprinterassetid'] is None
|
||||
|
||||
|
||||
def test_seedcandidate_carries_the_default_when_it_matched(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""A matched default is offered, so the common case is one click.
|
||||
|
||||
If the default were never proposed, every seeded bay would come back later
|
||||
for a second edit, and the half-seeded assignments in between are exactly
|
||||
the state that makes the register untrustworthy.
|
||||
"""
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('PRINTER-B', ADDRESS_B),
|
||||
])
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
|
||||
seed = _seedcandidate(_oneblock(response, BAY_HOST))
|
||||
assert set(seed['printerassetids']) == {_assetid(scene, 'A'),
|
||||
_assetid(scene, 'B')}
|
||||
assert seed['defaultprinterassetid'] == _assetid(scene, 'A')
|
||||
|
||||
|
||||
def test_reading_the_seedcandidate_writes_no_assignment(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""Offering is not applying.
|
||||
|
||||
A candidate that wrote itself on read would make every visit to an asset
|
||||
page adopt whatever that bay happened to have - the observed side quietly
|
||||
becoming the assigned side, which is the one thing this design forbids.
|
||||
"""
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [_queue('PRINTER-A', ADDRESS_A, isdefault=True)])
|
||||
before = _assignment_rows(scene)
|
||||
|
||||
response = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
assert _assignment_rows(scene) == before
|
||||
stored = client.get(ASSIGN_URL % scene['machine'].assetid,
|
||||
headers=auth_headers)
|
||||
assert stored.get_json()['data']['printerassetids'] == []
|
||||
|
||||
|
||||
def test_seeding_writes_the_assignment_only_when_a_person_saves_it(
|
||||
client, db, collector_key, scene, auth_headers):
|
||||
"""The seed is saved through the one existing write path, by hand.
|
||||
|
||||
Routing it through PUT /api/printers/assignments/for-asset keeps a single
|
||||
place where an assignment is written, so the reconcile rules - the
|
||||
default-must-be-in-the-set check, the soft delete, the printer-type
|
||||
validation - cannot be bypassed by a seed that grew its own endpoint.
|
||||
"""
|
||||
_controls(db, scene, scene['baypc'])
|
||||
_report(client, BAY_HOST, [
|
||||
_queue('PRINTER-A', ADDRESS_A, isdefault=True),
|
||||
_queue('PRINTER-B', ADDRESS_B),
|
||||
_queue('Reception Copier', ADDRESS_NOBODY),
|
||||
])
|
||||
|
||||
observed = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
assert observed.status_code == 200, observed.get_json()
|
||||
seed = _seedcandidate(_oneblock(observed, BAY_HOST))
|
||||
|
||||
# Seeded onto the MACHINE, which is where an assignment belongs: seeding the
|
||||
# PC would create own rows that permanently shadow the bay's and quietly
|
||||
# defeat reimage inheritance.
|
||||
saved = client.put(ASSIGN_URL % scene['machine'].assetid,
|
||||
headers=auth_headers,
|
||||
json={'printerassetids': list(seed['printerassetids']),
|
||||
'defaultprinterassetid': seed['defaultprinterassetid']})
|
||||
assert saved.status_code == 200, saved.get_json()
|
||||
|
||||
resolved = client.get(HOST_URL % BAY_HOST)
|
||||
installed = resolved.get_json()['data']['printers']
|
||||
assert {row['assetid'] for row in installed} == {_assetid(scene, 'A'),
|
||||
_assetid(scene, 'B')}
|
||||
default = [row['assetid'] for row in installed if row['isdefault']]
|
||||
assert default == [_assetid(scene, 'A')]
|
||||
|
||||
# The queue that matched nothing is still not an assignment, and the bay
|
||||
# still reports it - drift stays visible instead of being adopted.
|
||||
after = client.get(OBSERVED_URL % scene['baypc'].assetid,
|
||||
headers=auth_headers)
|
||||
classified = _classified(_oneblock(after, BAY_HOST))
|
||||
assert classified.get('unknown') == {'Reception Copier'}
|
||||
|
||||
|
||||
def test_a_host_that_changes_spelling_does_not_double_its_queues(client, db,
|
||||
scene,
|
||||
collector_key):
|
||||
"""The replace must cover every spelling of one host.
|
||||
|
||||
A PC enrolled short can later report its FQDN, or the other way round. The
|
||||
READ path already treats those as the same machine, so a delete matching
|
||||
only the exact string left the other spelling's rows behind, and the bay
|
||||
appeared to have every queue twice - which reads as drift that is not there,
|
||||
and would be adopted as a duplicate assignment.
|
||||
"""
|
||||
_report(client, 'OBSPC01', [_queue('CSF01-HP', address='10.0.0.5')])
|
||||
_report(client, 'obspc01.example.net', [_queue('CSF01-HP', address='10.0.0.5')])
|
||||
|
||||
# Counted across BOTH spellings, because the second report is stored under
|
||||
# the name it sent. What must be true is that one physical host holds one
|
||||
# row set, whichever spelling it last used.
|
||||
held = PrinterObservedQueue.query.filter(
|
||||
PrinterObservedQueue.hostname.ilike('obspc01%')).all()
|
||||
assert [row.queuename for row in held] == ['CSF01-HP'], (
|
||||
'the same queue was stored twice under two spellings of one host')
|
||||
Reference in New Issue
Block a user