GE-Enforce polish: DDL-parity guard test, retire Collector PC Types page
Add test_geenforce_ddl_parity to lock the manifest models against their Alembic baseline (catches model/migration drift for a chain that is still amendable pre-deploy). Retire the "Collector PC Types" settings card: GE-Enforce scope computertypeid supersedes the pctypemap editor UI (ADR-012). The collector still reads pctype_mapping(), so the backend map stays; only the editor surface is removed, with a deprecation note in pctypemap.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,58 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Collector PC Types</h2>
|
||||
<h2>Collector PC Types (retired)</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
When the collector ingests a PC, its imaging pc-type (from
|
||||
C:\Enrollment\pc-type.txt) is mapped to one of your Computer Types.
|
||||
Adjust the mapping per site.
|
||||
This page has been retired. Imaging pc-type handling now lives in
|
||||
GE-Enforce, where each imaging PC type is a manifest scope with its own
|
||||
Computer Type. Manage it from the GE-Enforce section instead.
|
||||
</p>
|
||||
|
||||
<div class="table-container" v-if="pcTypeMappings.length">
|
||||
<table class="identifier-matrix">
|
||||
<thead>
|
||||
<tr><th>Imaging pc-type</th><th>Computer Type</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pcTypeMappings" :key="row.pxetype">
|
||||
<td class="identifier-name">{{ row.pxetype }}</td>
|
||||
<td>
|
||||
<select
|
||||
:value="row.computertype"
|
||||
@change="changePcTypeMapping(row.pxetype, $event.target.value)"
|
||||
:disabled="saving"
|
||||
>
|
||||
<option v-for="ct in computerTypes" :key="ct" :value="ct">{{ ct }}</option>
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p v-else class="setting-description">No collector pc-type mappings are configured yet.</p>
|
||||
<p class="setting-description">
|
||||
The collector still applies the built-in pc-type to Computer Type
|
||||
defaults, so existing enrollment keeps working; there is nothing to
|
||||
configure here anymore.
|
||||
</p>
|
||||
<RouterLink class="btn" to="/geenforce/manifests">Open GE-Enforce</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useSystemSettings } from '../../composables/systemSettings'
|
||||
|
||||
const {
|
||||
saving, error, success, pcTypeMappings, computerTypes,
|
||||
loadSettings, loadComputerTypes, changePcTypeMapping,
|
||||
} = useSystemSettings()
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
loadComputerTypes()
|
||||
})
|
||||
// Deprecated page. Kept only so the /settings/pctypemapping route and old
|
||||
// bookmarks still resolve; GE-Enforce (scope computertypeid) supersedes the old
|
||||
// collector pc-type mapping UI. See ADR-012.
|
||||
import { RouterLink } from 'vue-router'
|
||||
</script>
|
||||
|
||||
@@ -92,7 +92,6 @@ export const settingsGroups = [
|
||||
{ to: '/settings/servicenow', icon: Link, title: 'ServiceNow', description: 'ServiceNow ticket links (incident, change) prefixes and global-search redirect' },
|
||||
{ to: '/settings/zabbix', icon: Droplets, title: 'Zabbix Supplies', description: 'Zabbix API for real-time printer toner and supply monitoring' },
|
||||
{ to: '/settings/dellwarranty', icon: ShieldCheck, title: 'Dell Warranty', description: 'Dell TechDirect warranty API lookup by service tag' },
|
||||
{ to: '/settings/pctypemapping', icon: Laptop, title: 'Collector PC Types', description: 'Map collector enrollment imaging pc-type (shopfloor) to a Computer Type' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,6 +23,9 @@ DEFAULT_PCTYPE_MAP = {
|
||||
'gea-shopfloor-partmarker': 'Shopfloor PC',
|
||||
}
|
||||
|
||||
# DEPRECATED settings surface: the "Collector PC Types" settings page is retired
|
||||
# (GE-Enforce scope computertypeid supersedes it, ADR-012). Collector still reads
|
||||
# this map, so keep the code; only the editor UI card is gone.
|
||||
_SETTING_PREFIX = 'pctypemap_'
|
||||
_SETTING_CATEGORY = 'pctypemapping'
|
||||
|
||||
|
||||
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
134
tests/test_plugins/test_geenforce_ddl_parity.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""GE-Enforce DDL parity gate: models match the hand-written Alembic baseline.
|
||||
|
||||
The geenforce baseline (0001_geenforce_baseline.py) actually CREATES the plugin
|
||||
tables (ADR-008 per-plugin chain), and it is hand-written - the models and the
|
||||
migration can drift silently (a column added to a model but not the migration
|
||||
ships a schema that upgrade() never builds, and vice versa). This guard builds
|
||||
the schema two ways and diffs them column-by-column:
|
||||
|
||||
1. the SQLAlchemy models via metadata.create_all (desired shape), and
|
||||
2. the migration's upgrade() run against a fresh engine (shipped shape).
|
||||
|
||||
Both go through the same SQLite dialect, so the reflected column types render
|
||||
identically and the comparison stays low-flake. A drift (missing column, changed
|
||||
type, changed nullability) fails here instead of on a real deploy insert.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
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')
|
||||
|
||||
|
||||
def _geenforce_models():
|
||||
from plugins.geenforce.models import manifest
|
||||
return [
|
||||
manifest.ManifestScope,
|
||||
manifest.ManifestEntry,
|
||||
manifest.ManifestEntryPcType,
|
||||
manifest.ManifestEntryHostname,
|
||||
manifest.ManifestEntryMachineNumber,
|
||||
manifest.ManifestInUseCheck,
|
||||
manifest.ManifestInUseCheckProcess,
|
||||
manifest.ManifestPublishedVersion,
|
||||
manifest.ManifestPayload,
|
||||
manifest.ManifestEnforcementReport,
|
||||
manifest.ManifestEnforcementResult,
|
||||
manifest.PcTypeAlias,
|
||||
]
|
||||
|
||||
|
||||
def _model_schema(db, models):
|
||||
"""Build the geenforce tables from the models; return an inspector."""
|
||||
engine = create_engine('sqlite://')
|
||||
db.metadata.create_all(engine, tables=[model.__table__ for model in models])
|
||||
return inspect(engine)
|
||||
|
||||
|
||||
def _migration_schema():
|
||||
"""Run the baseline migration's upgrade() on a fresh engine; return columns.
|
||||
|
||||
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)
|
||||
|
||||
engine = create_engine('sqlite://')
|
||||
schema = {}
|
||||
with engine.connect() as connection:
|
||||
operations = Operations(MigrationContext.configure(connection))
|
||||
operations._install_proxy()
|
||||
try:
|
||||
migration.upgrade()
|
||||
finally:
|
||||
operations._remove_proxy()
|
||||
connection.commit()
|
||||
inspector = inspect(connection)
|
||||
for table in inspector.get_table_names():
|
||||
schema[table] = {column['name']: column
|
||||
for column in inspector.get_columns(table)}
|
||||
return schema
|
||||
|
||||
|
||||
def _colkey(column):
|
||||
"""Normalize a reflected column to the facets we gate on."""
|
||||
return (str(column['type']), bool(column['nullable']))
|
||||
|
||||
|
||||
def test_models_match_migration_baseline(app, db):
|
||||
"""Every geenforce model table+column matches what the migration builds."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
drift = []
|
||||
for model in models:
|
||||
table = model.__tablename__
|
||||
if table not in migration_schema:
|
||||
drift.append(f'{table}: table missing from migration')
|
||||
continue
|
||||
modelcols = {column['name']: column
|
||||
for column in model_inspector.get_columns(table)}
|
||||
migrationcols = migration_schema[table]
|
||||
|
||||
missing = set(modelcols) - set(migrationcols)
|
||||
extra = set(migrationcols) - set(modelcols)
|
||||
for name in sorted(missing):
|
||||
drift.append(f'{table}.{name}: in model, not in migration')
|
||||
for name in sorted(extra):
|
||||
drift.append(f'{table}.{name}: in migration, not in model')
|
||||
for name in sorted(set(modelcols) & set(migrationcols)):
|
||||
if _colkey(modelcols[name]) != _colkey(migrationcols[name]):
|
||||
drift.append(
|
||||
f'{table}.{name}: model {_colkey(modelcols[name])} != '
|
||||
f'migration {_colkey(migrationcols[name])}')
|
||||
|
||||
assert not drift, 'model/migration DDL drift:\n' + '\n'.join(drift)
|
||||
|
||||
|
||||
def test_curated_and_payload_columns_present_both_sides(app, db):
|
||||
"""Spot-guard the two real-work columns: manifestentries.appid and
|
||||
manifestpayloads.payloadsha256 exist in BOTH the models and the migration
|
||||
(they were added for the app-link + inline-payload features)."""
|
||||
with app.app_context():
|
||||
models = _geenforce_models()
|
||||
model_inspector = _model_schema(db, models)
|
||||
migration_schema = _migration_schema()
|
||||
|
||||
for table, column in (('manifestentries', 'appid'),
|
||||
('manifestpayloads', 'payloadsha256')):
|
||||
modelcols = {c['name'] for c in model_inspector.get_columns(table)}
|
||||
assert column in modelcols, f'{table}.{column} missing from models'
|
||||
assert column in migration_schema[table], \
|
||||
f'{table}.{column} missing from migration'
|
||||
Reference in New Issue
Block a user