Prevent table and column alter and drop

Due to Cinder's rolling upgrade support we have to be very careful with
table drops and column alterations and drops.

In our Unit Tests legacy migrations walk, where we go through the
migrations to confirm they are properly applied, we raise an error on
those 3 operations to prevent them from being casually added in new
migrations.

Those checks are missing for the Alembic migrations, so this patch adds
them there as well while consolidating the check code for both types of
migrations into a single decorator called `prevent_drop_alter`.

Change-Id: I577f65968feb9c10488a19c6f99e8c0b720a433d
This commit is contained in:
Gorka Eguileor 2021-10-27 19:35:56 +02:00
parent 89b930f7ff
commit 1c9aac8f5d
2 changed files with 73 additions and 4 deletions

View File

@ -16,6 +16,9 @@ the test case runs a series of test cases to ensure that migrations work
properly and that no data loss occurs if possible.
"""
import functools
from unittest import mock
from alembic import command as alembic_api
from alembic import script as alembic_script
import fixtures
@ -33,6 +36,52 @@ from cinder.db.sqlalchemy import models
from cinder.tests import fixtures as cinder_fixtures
def prevent_drop_alter(func):
"""Decorator to prevent dropping or altering tables and columns.
With rolling upgrades we shouldn't blindly allow dropping or altering
tables and columns, since that can easily break them.
Dropping and altering should be done in a backward-compatible manner. A
more detailed explanation is provided in Cinder's developer documentation.
To properly work, the first parameter of the decorated method must be a
class or instance with the DROP_ALTER_EXCEPTIONS and FORBIDDEN_METHODS
attribute, and the second parameter must be the version (legacy migrations)
or revision (alembic migrations).
Reviewers should be very careful when adding exceptions to
DROP_ALTER_EXCEPTIONS and make sure that in the previous release there was
nothing using that column, not even an ORM model (unless the whole ORM
model was not being used)
"""
@functools.wraps(func)
def wrapper(self, revision, *args, **kwargs):
exceptions = getattr(self, 'DROP_ALTER_EXCEPTIONS', [])
do_ban = revision not in exceptions
patchers = []
if do_ban:
forbidden = getattr(self, 'FORBIDDEN_METHODS', [])
for path in forbidden:
txt = (f'Migration {revision}: Operation {path}() is not '
'allowed in a DB migration')
patcher = mock.patch(path, autospec=True,
side_effect=Exception(txt))
patcher.start()
patchers.append(patcher)
try:
return func(self, revision, *args, **kwargs)
finally:
for patcher in patchers:
patcher.stop()
return wrapper
class CinderModelsMigrationsSync(test_migrations.ModelsMigrationsSync):
"""Test sqlalchemy-migrate migrations."""
@ -122,6 +171,25 @@ class MigrationsWalk(
TIMEOUT_SCALING_FACTOR = 4
BOOL_TYPE = sqlalchemy.types.BOOLEAN
# NOTE: List of migrations where we allow dropping/altring things.
# Reviewers: DO NOT ALLOW THINGS TO BE ADDED HERE WITHOUT CARE, and make
# sure that in the previous release there was nothing using that column,
# not even an ORM model (unless the whole ORM model was not being used)
# See prevent_drop_alter method docstring.
DROP_ALTER_EXCEPTIONS = [
# Drops and alters from initial migration have already been accepted
'921e1a36b076',
# Making shared_targets explicitly nullable (DB already allowed it)
'c92a3e68beed',
# Migration 89aa6f9639f9 doesn't fail because it's for a SQLAlquemy
# internal table, and we only check Cinder's tables.
]
FORBIDDEN_METHODS = ('alembic.operations.Operations.alter_column',
'alembic.operations.Operations.drop_column',
'alembic.operations.Operations.drop_table',
'alembic.operations.BatchOperations.alter_column',
'alembic.operations.BatchOperations.drop_column')
def setUp(self):
super().setUp()
self.engine = enginefacade.writer.get_engine()
@ -129,6 +197,7 @@ class MigrationsWalk(
self.config = migration._find_alembic_conf()
self.init_version = '921e1a36b076'
@prevent_drop_alter
def _migrate_up(self, revision, connection):
check_method = getattr(self, f'_check_{revision}', None)
if revision != self.init_version: # no tests for the initial revision

View File

@ -92,10 +92,10 @@ Dropping a column not referenced in SQLAlchemy code
...................................................
When we want to remove a column that wasn't present in any SQLAlchemy model or
it was in the model, but model was not referenced in any SQLAlchemy API
function (this basically means that N-1 wasn't depending on the presence of
that column in the DB), then the situation is simple. We should be able to
safely drop the column in N release.
it was in the model, but model was not referenced anywhere in our code (this
basically means that N-1 wasn't depending on the presence of that column in the
DB), then the situation is simple. We should be able to safely drop the column
in N release.
Removal of unnecessary column
.............................