Collect what bays actually have, separately from what they are told to have
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m22s
CI / frontend (push) Failing after 7m14s
CI / migrations-mysql (push) Failing after 7m14s

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:
cproudlock
2026-08-19 14:45:39 -04:00
parent 1a5a1cd43d
commit 2d09fa3201
15 changed files with 2903 additions and 30 deletions

View 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')