The wj merge brought in a half-built fleet alert feature: server-side UI behaviour still being stood up over a delivery proof of concept. It left five red tests. This clears them and fixes a real hole underneath. The cross-machine guard never ran. acknowledge_fleet_alert read .assetid off _resolve_asset_for_hostname, which returns the id itself, so the check raised AttributeError on every call and answered 500. That guard is what stops one endpoint acknowledging another's alert, and the collector key is shared across the fleet, so until now any bay could clear an alert on another machine's behalf. Compare the id directly. Gate both alert routes the way the rest of the agent channel is gated. They are collector-key authenticated, not JWT+role, because a shop floor bay has no user session - the same shape as claim_next_command and upload_operator_report. Declared in the authz allowlists with the reason, rather than inventing a permission no agent could hold. Settle the delivery contract ahead of the durable agent work. The poll response is authoritative for a host: every alert that should be live on it right now. An alert absent from the feed is the agent's instruction to retire the pending toast, which is how a server-side close or cancel reaches a machine already displaying one. Acknowledgement is terminal per product decision, so a close after an ack is a no-op and the agent can retry a queued ack safely. A toast that merely timed out is not an acknowledgement. Stop reaching past the plugin contract surface for the mailer. One import was a plain redirect; the other pulled a private helper to pre-check recipients, duplicating what send_alert already does. It also returned early when no email recipients existed, which silently suppressed the independent webhook leg - misconfigured SMTP was dropping Teams alerts. Register NotificationAcknowledgement in get_models() and give the notifications plugin ownership of notificationacknowledgements, then move the expected head to notifications0006fleetalerts. Add the coverage whose absence let the .assetid bug ship. Thirteen tests over gating, authoritative-feed withdrawal, terminal and idempotent acknowledgement, per-asset scoping, and the cross-machine guard. Five of them fail against the pre-fix handler. Remaining suite failures are pre-existing and local: the symlink scan needs elevation, and the docs check wants files absent from this checkout.
273 lines
12 KiB
Python
273 lines
12 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',
|
|
'configurationpolicies', 'configurationpolicyactions',
|
|
'businesspolicies', 'businesspolicyitems',
|
|
'policyactionholds'),
|
|
'employees': ('directoryemployees',),
|
|
'endpoints': ('endpointstates', 'endpointevents', 'endpointcommands',
|
|
'endpointapplications', 'endpointmonitors',
|
|
'operatorreports', 'endpointhealthstates',
|
|
'endpointhealthtransitions', 'endpointmonitorimpacts',
|
|
'endpointlocks'),
|
|
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
|
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
|
'manifestinusechecks', 'manifestinusecheckprocesses',
|
|
'manifestpublishedversions', 'manifestpayloads',
|
|
'manifestblobs',
|
|
'manifestenforcementreports', 'manifestenforcementresults',
|
|
'pctypealiases'),
|
|
'guides': ('guides', 'guiderevisions', 'guideimages'),
|
|
'knowledgebase': ('knowledgebase',),
|
|
'machines': ('machinetypes', 'machines'),
|
|
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
|
'network': ('networkdevicetypes', 'networkdevices', 'vlans', 'subnets'),
|
|
'notifications': ('notificationtypes', 'notifications', 'notificationacknowledgements'),
|
|
'printedparts': ('printeditems', 'printeditemtransactions',
|
|
'printeditemfiles'),
|
|
'printers': ('printertypes', 'printers', 'modelsupplies', 'printerdrivers',
|
|
'printersupplyalerts', 'printerobservedqueues'),
|
|
'slides': ('tvslides',),
|
|
# Tech Tools was schemaless until secure send: every other tool in it
|
|
# runs in the browser and owns nothing.
|
|
'tools': ('securesendsecrets',),
|
|
'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).
|
|
|
|
NOT actually called from anywhere: every post-cutover baseline emits
|
|
explicit Alembic ops instead, because this helper's per-plugin MetaData
|
|
cannot resolve a foreign key to a CORE table at CreateTable-compile time.
|
|
Kept because it is still the reference for what idempotent means here, and
|
|
because create_table_if_missing below is the same rule in a form the
|
|
baselines can use. See that function for what went wrong without it.
|
|
"""
|
|
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 create_table_if_missing(table_name: str, *columns, **kwargs) -> bool:
|
|
"""op.create_table, skipped when the table is already there.
|
|
|
|
WHY EVERY POST-CUTOVER BASELINE NEEDS THIS. A baseline is the sole creator
|
|
of its plugin's tables, so it was written as a plain op.create_table - which
|
|
is correct only if the table's existence and the plugin's Alembic stamp can
|
|
never disagree. They can, and routinely do:
|
|
|
|
* `flask db-utils create-all` runs db.create_all(), which builds EVERY
|
|
table registered on the metadata - plugin tables included - and stamps
|
|
NOTHING. `flask plugin upgrade-all` then runs the baseline against
|
|
tables that already exist.
|
|
* A run that creates the table and then fails before its stamp commits
|
|
leaves the same split, and re-running is the obvious thing to try.
|
|
|
|
Reported as `flask plugin upgrade-all` failing the tools plugin with
|
|
"table securesendsecrets already exists". Nothing caught it because the
|
|
migration test drops the post-cutover tables db.create_all() made before
|
|
running upgrade-all, which arranges the one state where an unguarded create
|
|
works and never the state a real database is in.
|
|
|
|
Returns True when it created the table, so a caller can skip the indexes
|
|
that belong to it.
|
|
"""
|
|
from alembic import op
|
|
from sqlalchemy import inspect
|
|
|
|
if inspect(op.get_bind()).has_table(table_name):
|
|
logger.info('%s already exists, skipping create', table_name)
|
|
return False
|
|
op.create_table(table_name, *columns, **kwargs)
|
|
return True
|
|
|
|
|
|
def create_index_if_missing(index_name: str, table_name: str, columns, **kwargs):
|
|
"""op.create_index, skipped when an equivalent index already exists.
|
|
|
|
Matched on the COLUMN LIST rather than the name, because the two creators
|
|
do not agree on names. db.create_all() takes them from SQLAlchemy's default
|
|
convention (`ix_securesendsecrets_token`) while a baseline spells them the
|
|
way this repo's naming rules require (`ixsecuresendsecretstoken`). Matching
|
|
on the name would call the index missing and add a second one over the same
|
|
column - MySQL permits that, with only a warning, so the duplicate would
|
|
accumulate silently on every affected site.
|
|
"""
|
|
from alembic import op
|
|
from sqlalchemy import inspect
|
|
|
|
wanted = [str(column) for column in columns]
|
|
inspector = inspect(op.get_bind())
|
|
if not inspector.has_table(table_name):
|
|
op.create_index(index_name, table_name, columns, **kwargs)
|
|
return
|
|
for existing in inspector.get_indexes(table_name):
|
|
if list(existing.get('column_names') or []) == wanted:
|
|
logger.info('index over %s(%s) already exists as %s, skipping',
|
|
table_name, ', '.join(wanted), existing.get('name'))
|
|
return
|
|
op.create_index(index_name, table_name, columns, **kwargs)
|
|
|
|
|
|
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()
|