Files
shopdb-flask/shopdb/plugins/alembic_template.py
cproudlock 2d09fa3201
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
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.
2026-08-19 15:32:18 -04:00

195 lines
8.4 KiB
Python

"""Shared Alembic env.py logic for bundled plugins.
Every bundled plugin that owns tables (computers, employees, knowledgebase,
machines, network, notifications, printers, slides, usb, warranty) has a
`migrations/env.py` that does the minimum:
import os
os.environ['PLUGIN_NAME'] = 'computers'
from shopdb.plugins.alembic_template import run_migrations
run_migrations()
This module wires the plugin's models into a MetaData object filtered to
only the tables that belong to that plugin, then runs Alembic in either
offline or online mode against the Flask app's configured engine.
Ownership cutover (see ADR-008): the core Alembic chain created every table
that exists through its head (`7d16_directoryemployees`), including the plugin
tables. Each plugin's `0001` migration is therefore a stamp-only no-op that
just records the anchor revision in `alembic_version_<plugin>`. NEW plugin
schema changes land as `plugins/<name>/migrations/000N` from here on, never in
the core chain.
Plugin tables must be importable via `plugins.<name>.models`. Plugins
register their `__tablename__` set in PLUGIN_TABLE_OWNERS below so the
filter is explicit (avoids depending on import-side-effect global state).
"""
from __future__ import annotations
import importlib
import logging
import os
from typing import Iterable
from alembic import context
from sqlalchemy import MetaData, pool
# Registers the compiler hook that forces utf8mb4 + DYNAMIC on MySQL. Imported
# for the side effect. It used to live inline in migrations/env.py, so it applied
# to the CORE chain only: a plugin's baseline tables were created at the server's
# default charset while core's were utf8mb4, on the same database. On a server
# defaulting to latin1 that difference is invisible until a join between the two
# stops using an index, or a character comes back mangled.
import shopdb.utils.mysql_charset # noqa: F401,E402
logger = logging.getLogger('alembic.env.plugin')
# Explicit table-ownership map. Adding tables to a plugin requires updating
# this dict so the per-plugin migration knows which tables to include.
PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
'backups': ('backuprevisions',),
'computers': ('computertypes', 'computers', 'computerinstalledapps',
'accessprotocols', 'computeraccess'),
'employees': ('directoryemployees',),
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
'manifestentryhostnames', 'manifestentrymachinenumbers',
'manifestinusechecks', 'manifestinusecheckprocesses',
'manifestpublishedversions', 'manifestpayloads',
'manifestblobs',
'manifestenforcementreports', 'manifestenforcementresults',
'pctypealiases'),
'knowledgebase': ('knowledgebase',),
'machines': ('machinetypes', 'machines'),
'measuringtools': ('measuringtooltypes', 'measuringtools'),
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
'notifications': ('notificationtypes', 'notifications'),
'printedparts': ('printeditems', 'printeditemtransactions',
'printeditemfiles'),
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
'printersupplyalerts', 'printerobservedqueues'),
'slides': ('tvslides',),
'usb': ('usbdevicetypes', 'usbdevices', 'usbcheckouts'),
'warranty': ('warranties', 'warrantyassets'),
}
def _get_plugin_metadata(plugin_name: str) -> MetaData:
"""Import the plugin's models and return a MetaData containing only its
declared tables (filtered via PLUGIN_TABLE_OWNERS)."""
owned = set(PLUGIN_TABLE_OWNERS.get(plugin_name, ()))
if not owned:
raise RuntimeError(
f"PLUGIN_TABLE_OWNERS has no entry for plugin '{plugin_name}'. "
f"Update shopdb/plugins/alembic_template.py."
)
# Importing models attaches them to the global db.metadata.
importlib.import_module(f'plugins.{plugin_name}.models')
from shopdb.extensions import db
full = db.metadata
plugin_md = MetaData()
for table in list(full.tables.values()):
if table.name in owned:
table.to_metadata(plugin_md)
return plugin_md
def create_plugin_tables(plugin_name: str):
"""Create every table this plugin owns, sourced from the SQLAlchemy models
(not duplicated DDL). IDEMPOTENT: a table that already exists is skipped, so
this is safe on an existing database that has the table from the pre-cutover
core baseline as well as on a fresh install (ADR-014 Phase 2).
Called from each plugin's 0001 baseline.py upgrade().
"""
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.schema import CreateTable
md = _get_plugin_metadata(plugin_name)
bind = op.get_bind()
existing = set(inspect(bind).get_table_names())
# Sort by FK dependency so parent tables are created first.
for table in md.sorted_tables:
if table.name in existing:
continue
op.execute(str(CreateTable(table).compile(dialect=bind.dialect)))
def drop_plugin_tables(plugin_name: str):
"""Mirror of create_plugin_tables for downgrade(). Drops in reverse FK
order."""
from alembic import op
md = _get_plugin_metadata(plugin_name)
for table in reversed(md.sorted_tables):
op.execute(f'DROP TABLE IF EXISTS "{table.name}"')
def run_migrations():
"""Entry point called by each plugin's migrations/env.py."""
plugin_name = os.environ.get('PLUGIN_NAME')
if not plugin_name:
raise RuntimeError("PLUGIN_NAME env var must be set before run_migrations()")
config = context.config
target_metadata = _get_plugin_metadata(plugin_name)
# Per-plugin version table so each plugin's chain is independent of core
# Alembic's alembic_version table.
version_table = f'alembic_version_{plugin_name}'
db_url = config.get_main_option('sqlalchemy.url')
if not db_url:
# Pull from the Flask app config if running inside an app context
# (e.g. via flask plugin migrate <name>).
try:
from flask import current_app
db_url = current_app.config['SQLALCHEMY_DATABASE_URI']
config.set_main_option('sqlalchemy.url', db_url.replace('%', '%%'))
except Exception as ex:
raise RuntimeError(
"sqlalchemy.url not set and no Flask app context available. "
f"Original error: {ex}"
)
if context.is_offline_mode():
context.configure(
url=db_url,
target_metadata=target_metadata,
literal_binds=True,
version_table=version_table,
include_schemas=False,
)
with context.begin_transaction():
context.run_migrations()
else:
# Build the engine straight from the resolved URL. The plugin manager
# drives this via a programmatic alembic Config (no ini file), so
# config.get_section returns an empty dict and engine_from_config would
# find no sqlalchemy.url. db_url is already resolved above.
from sqlalchemy import create_engine
connectable = create_engine(db_url, poolclass=pool.NullPool)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table=version_table,
include_schemas=False,
)
with context.begin_transaction():
context.run_migrations()
# SAME FIX AS migrations/env.py. MySQL DDL commits implicitly, which
# flushes everything queued before it - including the PREVIOUS
# migration's version stamp. The LAST migration of a run has no DDL
# after it, so its stamp is rolled back when the connection closes:
# the schema change survives, alembic_version stays one revision
# behind, and `flask plugin upgrade-all` exits 0 having silently
# re-run that migration - again on every deploy after.
#
# Core got this fix; the per-plugin chains run through THIS file and
# did not, so every plugin head has been re-running on MySQL. That is
# only invisible while every head happens to be idempotent.
connection.commit()