Hard delete applications, nest installer packages, detect PE versions
Soft delete left orphaned executables on disk and let a deleted name collide with a fresh import, so delete is now a hard delete that removes the associated files. The destructive paths (delete, revert-to-record) require a typed OK because neither can be undone. Installers keep their filename at import and nest per application/version, which stops repeat imports doubling up. Version now comes from the same PE detection the endpoints agent uses; when no version can be read the caller is asked for one rather than silently storing a blank. Applications also surface Version and a monitorable toggle, with launch path and process name required only while monitorable is ticked. Fleet shows the PC alias, matching PC Details.
This commit is contained in:
86
migrations/versions/7d45_purge_inactive_apps.py
Normal file
86
migrations/versions/7d45_purge_inactive_apps.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Purge soft-deleted applications that still reserve their names.
|
||||
|
||||
Delete used to set isactive=False and leave the row in place. The uniqueness
|
||||
check on create looks at every row regardless of isactive, so each of those
|
||||
rows kept its name reserved permanently: creating an application with the same
|
||||
name failed with "already exists" against a row no screen would display, and
|
||||
nothing in the UI could clear it.
|
||||
|
||||
Delete is a hard delete now, but that only fixes rows deleted from here on.
|
||||
This clears the ones already stranded, in every environment rather than just
|
||||
the one where the bug was noticed.
|
||||
|
||||
Dependent rows are cleared the same way the API does it, and for the same
|
||||
reasons: appversions and computerinstalledapps are NOT NULL and meaningless
|
||||
without their application, while knowledgebase.appid is nullable and the
|
||||
article is somebody's writing, so it is unlinked rather than destroyed.
|
||||
|
||||
Uploaded installers belonging to these rows are deliberately NOT touched. A
|
||||
migration that deletes files cannot be rolled back and cannot be dry-run, and a
|
||||
stray file is harmless next to that risk. Deletes made through the API do clean
|
||||
them up.
|
||||
|
||||
Revision ID: 7d45_purge_inactive_apps
|
||||
Revises: 7d44_app_monitorable
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision = '7d45_purge_inactive_apps'
|
||||
down_revision = '7d44_app_monitorable'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _table_exists(bind, name):
|
||||
"""True when a table is present.
|
||||
|
||||
The plugin tables are only there when the plugin is installed, so each is
|
||||
checked before being touched.
|
||||
"""
|
||||
return sa.inspect(bind).has_table(name)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
if not _table_exists(bind, 'applications'):
|
||||
return
|
||||
|
||||
doomed = [row[0] for row in bind.execute(sa.text(
|
||||
'SELECT appid FROM applications WHERE isactive = false'
|
||||
))]
|
||||
|
||||
if not doomed:
|
||||
return
|
||||
|
||||
for appid in doomed:
|
||||
params = {'appid': appid}
|
||||
|
||||
if _table_exists(bind, 'knowledgebase'):
|
||||
bind.execute(sa.text(
|
||||
'UPDATE knowledgebase SET appid = NULL WHERE appid = :appid'
|
||||
), params)
|
||||
|
||||
if _table_exists(bind, 'computerinstalledapps'):
|
||||
bind.execute(sa.text(
|
||||
'DELETE FROM computerinstalledapps WHERE appid = :appid'
|
||||
), params)
|
||||
|
||||
if _table_exists(bind, 'appversions'):
|
||||
bind.execute(sa.text(
|
||||
'DELETE FROM appversions WHERE appid = :appid'
|
||||
), params)
|
||||
|
||||
bind.execute(sa.text(
|
||||
'DELETE FROM applications WHERE appid = :appid'
|
||||
), params)
|
||||
|
||||
|
||||
def downgrade():
|
||||
# The rows are gone and their contents were not captured, so there is
|
||||
# nothing to restore. Failing loudly is better than pretending otherwise.
|
||||
raise NotImplementedError(
|
||||
'Purged applications cannot be restored; recover from a backup.')
|
||||
136
migrations/versions/7d46_nest_app_packages.py
Normal file
136
migrations/versions/7d46_nest_app_packages.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Move uploaded installers into per-application directories.
|
||||
|
||||
Installers used to be stored flat as "application-<id>.<ext>", which threw away
|
||||
the name the vendor shipped. That is what made a re-import look like new
|
||||
software: the operator had no way to see that the file already in the catalogue
|
||||
was the same one they were about to upload again, so duplicate applications and
|
||||
versions accumulated. Installers now keep their filename, namespaced by
|
||||
application id so a generic "setup.exe" from two vendors cannot collide.
|
||||
|
||||
That only helps files uploaded from here on. This moves the ones already stored,
|
||||
so existing download links keep working instead of 404ing against a path the
|
||||
route no longer serves.
|
||||
|
||||
The original filename cannot be recovered - the old scheme discarded it at
|
||||
upload - so these land as "installer.<ext>". That is no worse than what they
|
||||
were called before, and re-uploading the installer restores the real name.
|
||||
|
||||
Files are MOVED, not copied or deleted: a move is reversible, which is what
|
||||
lets downgrade() put them back. This is deliberately unlike 7d45, which refused
|
||||
to touch files because it could only have deleted them.
|
||||
|
||||
The database is the part that must not be left inconsistent, so installpath is
|
||||
rewritten for every affected row whether or not its file was found on disk. A
|
||||
row pointing at a missing file is a broken download; a row pointing at the old
|
||||
scheme is a broken download that also cannot be repaired by re-uploading.
|
||||
|
||||
Revision ID: 7d46_nest_app_packages
|
||||
Revises: 7d45_purge_inactive_apps
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = '7d46_nest_app_packages'
|
||||
down_revision = '7d45_purge_inactive_apps'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
OLD_PREFIX = '/api/applications/package/'
|
||||
|
||||
# application-<id>.<ext>, the only shape the old uploader produced.
|
||||
OLD_NAME = re.compile(r'^application-(\d+)\.([A-Za-z0-9]+)$')
|
||||
|
||||
|
||||
def _package_dir():
|
||||
"""The instance-relative directory installers are stored in.
|
||||
|
||||
Resolved the same way the API resolves it. If the app context is not
|
||||
available the migration still rewrites the database and simply leaves the
|
||||
files alone, rather than failing the whole upgrade over a path lookup.
|
||||
"""
|
||||
try:
|
||||
from flask import current_app
|
||||
return os.path.join(current_app.instance_path, 'applicationpackages')
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(sa.text(
|
||||
"SELECT appid, installpath FROM applications "
|
||||
"WHERE installpath LIKE :prefix"
|
||||
), {'prefix': OLD_PREFIX + '%'}).fetchall()
|
||||
|
||||
directory = _package_dir()
|
||||
|
||||
for appid, installpath in rows:
|
||||
tail = installpath[len(OLD_PREFIX):]
|
||||
match = OLD_NAME.match(tail)
|
||||
if not match:
|
||||
# Already nested (contains a slash) or otherwise not the old shape.
|
||||
# Leaving it alone is correct: it is already being served.
|
||||
continue
|
||||
|
||||
ext = match.group(2).lower()
|
||||
stored = f'installer.{ext}'
|
||||
|
||||
if directory:
|
||||
source = os.path.join(directory, tail)
|
||||
target_dir = os.path.join(directory, str(appid))
|
||||
if os.path.isfile(source):
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
try:
|
||||
os.replace(source, os.path.join(target_dir, stored))
|
||||
except OSError:
|
||||
# Best effort. The row is still repointed below so the
|
||||
# schema is consistent; a missing file is a re-upload.
|
||||
pass
|
||||
|
||||
bind.execute(sa.text(
|
||||
"UPDATE applications SET installpath = :path WHERE appid = :appid"
|
||||
), {'path': f'{OLD_PREFIX}{appid}/{stored}', 'appid': appid})
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
rows = bind.execute(sa.text(
|
||||
"SELECT appid, installpath FROM applications "
|
||||
"WHERE installpath LIKE :prefix"
|
||||
), {'prefix': OLD_PREFIX + '%'}).fetchall()
|
||||
|
||||
directory = _package_dir()
|
||||
|
||||
for appid, installpath in rows:
|
||||
tail = installpath[len(OLD_PREFIX):]
|
||||
if '/' not in tail:
|
||||
continue
|
||||
nested_id, _, stored = tail.partition('/')
|
||||
if nested_id != str(appid):
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(stored)[1].lstrip('.').lower()
|
||||
if not ext:
|
||||
continue
|
||||
flat = f'application-{appid}.{ext}'
|
||||
|
||||
if directory:
|
||||
source = os.path.join(directory, str(appid), stored)
|
||||
if os.path.isfile(source):
|
||||
try:
|
||||
os.replace(source, os.path.join(directory, flat))
|
||||
# Remove the now-empty directory so a re-upgrade does not
|
||||
# find a stale one. Only if empty: a directory with other
|
||||
# files in it is not ours to remove.
|
||||
os.rmdir(os.path.join(directory, str(appid)))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
bind.execute(sa.text(
|
||||
"UPDATE applications SET installpath = :path WHERE appid = :appid"
|
||||
), {'path': OLD_PREFIX + flat, 'appid': appid})
|
||||
Reference in New Issue
Block a user