geenforce: let a kiosk say what it is instead of guessing

A display knows whether it is a Dashboard, a Lobby screen or the 3D print
room - the dispatcher reads C:\Enrollment\display-type.txt to choose which
page to open. It never told shopdb, so the fleet table inferred it from the
DashboardDefault fqdn mapping, which is empty unless somebody added a row
per kiosk. The column was blank for every display.

The client now reads that file and reports it, the report stores it, and
the API prefers the reported value with the old mapping left as a fallback
for hosts still on an older client. Reported by the device beats inferred
from a lookup table, the same way enforcerversion already works. A PC with
no display-type.txt reports nothing rather than something invented, and an
empty string lands as NULL.

Two guards had to learn about it. The DDL parity check read only the 0001
baseline, so a column added by a later revision looked like drift even
though its migration existed; it now runs the whole chain, which is what
'do the models match what the migrations build' means. 0002 added a whole
table rather than a column, which is why this is the first time it bit.
This commit is contained in:
cproudlock
2026-08-12 17:07:21 -04:00
parent d572c913e5
commit 84bf5d04ed
8 changed files with 120 additions and 13 deletions

View File

@@ -1106,7 +1106,9 @@ def list_reports():
'machineassetid': known.get('machineassetid'),
'toolassetnumber': known.get('toolassetnumber'),
'toolassetid': known.get('toolassetid'),
'displayrole': known.get('displayrole'),
# Reported by the device wins; the DashboardDefault mapping is a
# fallback for hosts on an older client that does not send it.
'displayrole': report.subtype or known.get('displayrole'),
'backupkind': known.get('backupkind'),
'backuplastseen': known.get('backuplastseen'),
'scopename': report.scopename,

View File

@@ -210,7 +210,8 @@ function New-ShopdbReport {
param([string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory)][string]$Scope,
[int]$AppliedVersion,
[Parameter(Mandatory)][hashtable]$Summary)
[Parameter(Mandatory)][hashtable]$Summary,
[string]$SubType = (Get-ShopdbSubType))
$results = @(foreach ($entry in @($Summary.Results)) {
if ($null -eq $entry) { continue }
$mapped = @{
@@ -230,6 +231,7 @@ function New-ShopdbReport {
scopename = $Scope
appliedversion = $AppliedVersion
enforcerversion = $Summary.EnforcerVersion
subtype = $SubType
counts = @{
installed = [int]$Summary.Installed
skipped = [int]$Summary.Skipped
@@ -240,6 +242,32 @@ function New-ShopdbReport {
}
}
function Get-ShopdbSubType {
<#
.SYNOPSIS
What this PC is WITHIN its scope, read off the machine itself.
.DESCRIPTION
A display knows whether it is a Dashboard, a Lobby screen or the 3D print
room: the dispatcher reads C:\Enrollment\display-type.txt to choose which
page to open. It just never told shopdb, so the fleet table had to guess
from a DashboardDefault fqdn mapping that is empty unless somebody added a
row per kiosk. Reported by the device beats inferred from a lookup table,
the same way enforcerversion already works.
Returns '' when the file is absent - every non-display PC type - so the
report simply carries no subtype rather than a made-up one.
#>
param([string]$Path = 'C:\Enrollment\display-type.txt')
try {
if (-not (Test-Path $Path)) { return '' }
$value = (Get-Content -Path $Path -TotalCount 1 -ErrorAction Stop)
return ([string]$value).Trim()
} catch {
return ''
}
}
function Get-ShopdbPayload {
<#
Fetch a payload blob by content hash over HTTPS, verify the sha256, and
@@ -449,4 +477,4 @@ function ConvertTo-ShopdbSummary {
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion, `
Get-ShopdbPayload, Resolve-ShopdbPayloads, Merge-ShopdbManifests, `
ConvertTo-ShopdbSummary
ConvertTo-ShopdbSummary, Get-ShopdbSubType

View File

@@ -0,0 +1,28 @@
"""Record the display subtype a PC reports for itself.
A kiosk knows whether it is a Dashboard, Lobby or 3DPrintRoom - the dispatcher
reads C:\\Enrollment\\display-type.txt to pick its target - but it never told
ShopDB, so the reports table had to infer it from the DashboardDefault
fqdn/ip mapping, which is blank unless someone added a row per kiosk. Reported
by the device beats inferred from a mapping table, the same way enforcerversion
already works.
Revision ID: geenforce0003subtype
Revises: geenforce0002blobs
"""
from alembic import op
import sqlalchemy as sa
revision = 'geenforce0003subtype'
down_revision = 'geenforce0002blobs'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('manifestenforcementreports',
sa.Column('subtype', sa.String(length=50), nullable=True))
def downgrade():
op.drop_column('manifestenforcementreports', 'subtype')

View File

@@ -295,6 +295,11 @@ class ManifestEnforcementReport(db.Model):
# published version to see whether this PC received the latest manifest.
appliedversion = db.Column(db.Integer, nullable=True)
enforcerversion = db.Column(db.String(20), nullable=True)
# What the PC says it IS within its scope (a display's Dashboard / Lobby /
# 3DPrintRoom). Reported by the device, which reads it from its own
# enrollment file - not inferred from a mapping table that may have no row
# for this host.
subtype = db.Column(db.String(50), nullable=True)
installedcount = db.Column(db.Integer, nullable=False, default=0)
skippedcount = db.Column(db.Integer, nullable=False, default=0)
failedcount = db.Column(db.Integer, nullable=False, default=0)

View File

@@ -263,6 +263,7 @@ def record_enforcement_report(payload):
phase=phase,
appliedversion=payload.get('appliedversion'),
enforcerversion=payload.get('enforcerversion'),
subtype=(payload.get('subtype') or None),
installedcount=installed,
skippedcount=int(counts.get('skipped', 0)),
failedcount=failed,

View File

@@ -52,7 +52,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen'
# geenforce adds the content-addressed blob store (manifestblobs) on top of its
# baseline.
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0002blobs'
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0003subtype'
# printers adds the printersupplyalerts crossing-state table on top of its anchor.
EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts'
# machines (renamed from equipment) keeps its original anchor id and adds the

View File

@@ -22,9 +22,9 @@ from alembic.migration import MigrationContext
from alembic.operations import Operations
_BASELINE = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
'geenforce' / 'migrations' / 'versions' /
'0001_geenforce_baseline.py')
_VERSIONS = (Path(__file__).resolve().parent.parent.parent / 'plugins' /
'geenforce' / 'migrations' / 'versions')
_BASELINE = _VERSIONS / '0001_geenforce_baseline.py'
def _geenforce_models():
@@ -53,15 +53,23 @@ def _model_schema(db, models):
def _migration_schema():
"""Run the baseline migration's upgrade() on a fresh engine; return columns.
"""Run the WHOLE chain's upgrade() on a fresh engine; return columns.
The whole chain, not just the baseline. Reading only 0001 meant a column
added by a later revision read as model/migration drift even though the
migration for it existed - the check is "do the models match what the
migrations build", and that is the chain, not its first file.
Returns {tablename: {columnname: reflected-column-dict}}. Reflection happens
while the connection is open, so we materialize the dicts before it closes.
"""
spec = importlib.util.spec_from_file_location('geenforce_baseline',
str(_BASELINE))
migration = importlib.util.module_from_spec(spec)
spec.loader.exec_module(migration)
migrations = []
for path in sorted(_VERSIONS.glob('[0-9]*.py')):
spec = importlib.util.spec_from_file_location(
'geenforce_migration_' + path.stem, str(path))
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
migrations.append(module)
engine = create_engine('sqlite://')
schema = {}
@@ -69,7 +77,8 @@ def _migration_schema():
operations = Operations(MigrationContext.configure(connection))
operations._install_proxy()
try:
migration.upgrade()
for migration in migrations:
migration.upgrade()
finally:
operations._remove_proxy()
connection.commit()

View File

@@ -193,3 +193,37 @@ def test_backup_state_prefers_the_newest_confirmed_revision(db):
assert facts['wjpc01']['backupkind'] == 'udc'
assert facts['wjpc01']['backuplastseen'] is not None
# -- reported display subtype -------------------------------------------------
def test_report_stores_the_subtype_the_device_reports(db):
"""A kiosk knows whether it is a Dashboard or a Lobby; it now says so.
Previously the fleet table inferred this from the DashboardDefault fqdn
mapping, which is empty unless somebody added a row per kiosk, so the
column was blank for every display.
"""
from plugins.geenforce.service import record_enforcement_report
record_enforcement_report({'hostname': 'WJKIOSK01', 'scopename': 'gea-shopfloor-display',
'subtype': 'Lobby', 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJKIOSK01', iscurrent=True).one()
assert stored.subtype == 'Lobby'
def test_report_without_a_subtype_stores_none(db):
"""Every non-display PC type has no display-type.txt, so it reports no
subtype - that must land as NULL, not as an empty string."""
from plugins.geenforce.service import record_enforcement_report
record_enforcement_report({'hostname': 'WJPC77', 'scopename': 'common',
'subtype': '', 'counts': {}})
db.session.flush()
stored = ManifestEnforcementReport.query.filter_by(
hostname='WJPC77', iscurrent=True).one()
assert stored.subtype is None