A fresh install could not build its schema. Core revision 7d51_endpoint_logcollections creates endpointlogcollections with a foreign key onto endpointcommands, but PLUGIN_TABLE_OWNERS lists both tables as owned by the endpoints plugin, and only the plugin's 0002 revision creates endpointcommands. Core migrations run before any plugin chain, and a plugin chain only runs once that plugin is enabled, which cannot happen before the schema exists. So there is no ordering in which the core revision can succeed on an empty database. MySQL rejects it with 1824, 'Failed to open the referenced table endpointcommands'. That is the first command the README gives an adopting site, on the branch that exists for sites to adopt. Nothing caught it because the suite runs on sqlite:///:memory: and SQLite accepts a foreign key onto a table that does not exist. The failure is MySQL-only and therefore invisible to every test. Fix: - endpoints revision 0025 creates the table in the plugin chain, after 0002 has created its foreign key target. Guarded, so it is a no-op on sites that already applied the core revision. - 7d51 skips when endpointcommands is absent, which is exactly the fresh install case 0025 then covers. Its downgrade is guarded to match. - EXPECTED_HEAD_REVISION records the new endpoints head. Verified against real MySQL 8.4, not SQLite: on an empty database the README sequence now completes, 7d51 stamps without creating anything, and enabling the endpoints plugin builds endpointlogcollections with endpointlogcollections_ibfk_2 pointing at endpointcommands.
102 lines
4.5 KiB
Python
102 lines
4.5 KiB
Python
"""Move endpointlogcollections into the chain that owns it.
|
|
|
|
Revision ID: endpoints0025logcollections
|
|
Revises: endpoints0024bootstraptokens
|
|
Create Date: 2026-09-19
|
|
|
|
WHY THIS REVISION EXISTS. endpointlogcollections was created by a CORE
|
|
migration (7d51_endpoint_logcollections) even though PLUGIN_TABLE_OWNERS has
|
|
always listed it, and endpointcommands, as owned by this plugin. The core table
|
|
carries a foreign key onto endpointcommands, which only 0002 creates. Core runs
|
|
before any plugin chain, and a plugin chain only runs once the plugin is
|
|
enabled, which cannot happen before the schema exists. So on a FRESH database
|
|
there is no ordering in which the core revision can succeed: MySQL rejects it
|
|
with 1824, "Failed to open the referenced table 'endpointcommands'".
|
|
|
|
Nothing caught it because TestingConfig runs on sqlite:///:memory:, and SQLite
|
|
accepts a foreign key onto a table that does not exist. The failure is
|
|
MySQL-only, and therefore invisible to the whole test suite.
|
|
|
|
Existing sites already ran the core revision and have the table, so this is
|
|
guarded and is a no-op there. 7d51 is guarded in the same change so it skips
|
|
when endpointcommands is absent, which is exactly the fresh-install case this
|
|
revision then handles.
|
|
|
|
Downgrade drops the table.
|
|
"""
|
|
from alembic import op
|
|
|
|
from shopdb.plugins.alembic_template import (
|
|
create_index_if_missing,
|
|
create_table_if_missing,
|
|
)
|
|
|
|
import sqlalchemy as sa
|
|
|
|
|
|
revision = 'endpoints0025logcollections'
|
|
down_revision = 'endpoints0024bootstraptokens'
|
|
branch_labels = None
|
|
depends_on = None
|
|
plugin_name = 'endpoints'
|
|
|
|
COLLECTIONS = 'endpointlogcollections'
|
|
|
|
|
|
def upgrade():
|
|
created = create_table_if_missing(
|
|
COLLECTIONS,
|
|
sa.Column('endpointlogcollectionid', sa.Integer(), nullable=False),
|
|
sa.Column('assetid', sa.Integer(), nullable=False),
|
|
sa.Column('endpointcommandid', sa.Integer(), nullable=True),
|
|
sa.Column('sourcepath', sa.String(length=512), nullable=False,
|
|
comment='Path as the AGENT resolved it, which may differ '
|
|
'from what the operator typed once environment '
|
|
'variables are expanded endpoint-side.'),
|
|
sa.Column('content', sa.Text(length=16777215), nullable=True),
|
|
sa.Column('filesizebytes', sa.BigInteger(), nullable=True,
|
|
comment='Size of the file on disk. Differs from contentbytes '
|
|
'when the stored copy is a truncated tail.'),
|
|
sa.Column('contentbytes', sa.Integer(), nullable=True),
|
|
sa.Column('linecount', sa.Integer(), nullable=True),
|
|
sa.Column('istruncated', sa.Boolean(), nullable=False,
|
|
server_default=sa.false(),
|
|
comment='True when content is only the tail of a larger file.'),
|
|
sa.Column('filemodifiedat', sa.DateTime(), nullable=True),
|
|
sa.Column('collectedbyuserid', sa.Integer(), nullable=True),
|
|
sa.Column('collectedbyusername', sa.String(length=100), nullable=True,
|
|
comment='Username snapshot; the id alone becomes unreadable '
|
|
'if the user is later removed.'),
|
|
sa.Column('collectedat', sa.DateTime(), nullable=False),
|
|
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
|
|
ondelete='CASCADE'),
|
|
# SET NULL rather than CASCADE: commands are reaped and expired over
|
|
# time, and a collected log an operator is still reading must not
|
|
# vanish because its originating command row was cleaned up.
|
|
sa.ForeignKeyConstraint(['endpointcommandid'],
|
|
['endpointcommands.endpointcommandid'],
|
|
ondelete='SET NULL'),
|
|
sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'],
|
|
ondelete='SET NULL'),
|
|
sa.PrimaryKeyConstraint('endpointlogcollectionid'),
|
|
)
|
|
if not created:
|
|
return
|
|
|
|
create_index_if_missing('ix_endpointlogcollections_assetid',
|
|
COLLECTIONS, ['assetid'])
|
|
create_index_if_missing('ix_endpointlogcollections_collectedat',
|
|
COLLECTIONS, ['collectedat'])
|
|
# Serves both hot paths: listing an asset's history newest-first, and
|
|
# finding that asset's oldest rows to prune on insert.
|
|
create_index_if_missing('idx_endpointlogcollections_assetcollected',
|
|
COLLECTIONS, ['assetid', 'collectedat'])
|
|
|
|
|
|
def downgrade():
|
|
from sqlalchemy import inspect
|
|
|
|
if not inspect(op.get_bind()).has_table(COLLECTIONS):
|
|
return
|
|
op.drop_table(COLLECTIONS)
|