"""Add endpointlogcollections Backs the manual Log Collector tab: one row per plaintext log file an operator collected from an endpoint by path. Collected content gets its own table rather than living in endpointcommands.resultjson for three reasons. It is bulky, and resultjson is read by every command-history list that only wants a status. It needs its own retention rule, which a command row cannot carry without deleting the audit record of the command itself. And the download route has to address a single collection by id and serve it as a file, which is awkward against a JSON column. Retention is PER ASSET (20, enforced on insert in the result route), not global. The history renders on a single machine's detail page, so a global cap would let one busy endpoint quietly evict the history of another - the panel would be unreliable exactly where it is read. endpointcommandid is 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. assetid stays CASCADE because a log has no meaning once the machine is gone. filesizebytes is BIGINT while contentbytes is INTEGER on purpose - the file on disk may be gigabytes, while what we store is a bounded tail. Revision ID: 7d51_endpoint_logcollections Revises: 7d50_script_captureoutput Create Date: 2026-09-09 """ from alembic import op import sqlalchemy as sa revision = '7d51_endpoint_logcollections' down_revision = '7d50_script_captureoutput' branch_labels = None depends_on = None def upgrade(): op.create_table( 'endpointlogcollections', 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'), sa.ForeignKeyConstraint(['endpointcommandid'], ['endpointcommands.endpointcommandid'], ondelete='SET NULL'), sa.ForeignKeyConstraint(['collectedbyuserid'], ['users.userid'], ondelete='SET NULL'), sa.PrimaryKeyConstraint('endpointlogcollectionid'), ) op.create_index('ix_endpointlogcollections_assetid', 'endpointlogcollections', ['assetid']) op.create_index('ix_endpointlogcollections_collectedat', 'endpointlogcollections', ['collectedat']) # Serves both hot paths: listing an asset's history newest-first, and # finding that asset's oldest rows to prune on insert. op.create_index('idx_endpointlogcollections_assetcollected', 'endpointlogcollections', ['assetid', 'collectedat']) def downgrade(): op.drop_index('idx_endpointlogcollections_assetcollected', table_name='endpointlogcollections') op.drop_index('ix_endpointlogcollections_collectedat', table_name='endpointlogcollections') op.drop_index('ix_endpointlogcollections_assetid', table_name='endpointlogcollections') op.drop_table('endpointlogcollections')