Printer assignment end to end, and a container that keeps a site's files

Assign printers to a MACHINE and let whichever PC controls it install them, with
the observed half kept strictly apart from the assigned half: a collector report
can never write an assignment row. Adoption is explicit. Adds a singular
relationship type so defaultprinter can mean exactly one, client scripts that
converge a bay's queues and correct a drifted one in place, and a wave gate so a
fleet-wide driver rollout cannot stampede its own share.

On the container side: the compose stack never persisted the instance directory,
so a rebuild discarded plugins.json and every upload. It is a volume now, the
image ships instance/ owned by the app user so the mountpoint is writable, and
the database moves to MySQL 8.4 LTS, which the Windows runbook has recommended
since 8.0 went end of life. The image also could not build at all: the frontend
stage never copied the plugin staging script it runs.

Adds a topology migration guide covering IIS to Docker and back.
This commit is contained in:
2026-08-20 07:46:35 -04:00
parent 09e9d5f047
commit d267813999
45 changed files with 6475 additions and 56 deletions

View File

@@ -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