From 5ea7b04b15a1532f33591f16caa6d51eb966efcc Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Thu, 24 Oct 2024 14:37:02 +0100 Subject: [PATCH 1/5] Add command to rollback migrations --- .../rollbackmigrationsappliedafter.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 api/core/management/commands/rollbackmigrationsappliedafter.py diff --git a/api/core/management/commands/rollbackmigrationsappliedafter.py b/api/core/management/commands/rollbackmigrationsappliedafter.py new file mode 100644 index 000000000000..a37a2e52e785 --- /dev/null +++ b/api/core/management/commands/rollbackmigrationsappliedafter.py @@ -0,0 +1,33 @@ +from argparse import ArgumentParser +from datetime import datetime, timezone + +from django.core.management import BaseCommand, CommandError, call_command +from django.db.migrations.recorder import MigrationRecorder + + +class Command(BaseCommand): + """ + Rollback all migrations applied on or after a given date. + + Usage: python manage.py rollbackmigrationsappliedafter --date 2024-10-24 + """ + + def add_arguments(self, parser: ArgumentParser): + parser.add_argument( + "--date", + type=str, + required=True, + help="Rollback all migrations applied on or after this UTC date (provided in the format YYYY-MM-DD)", + ) + + def handle(self, *args, date: str, **kwargs) -> None: + try: + _date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + except ValueError: + raise CommandError("Date must be in format YYYY-MM-DD") + + applied_migrations = MigrationRecorder.Migration.objects.filter(applied__gte=_date) + for migration in applied_migrations: + migration_number = migration.name.split("_", maxsplit=1)[0] + rollback_to = int(migration_number) - 1 + call_command("migrate", migration.app, f"{rollback_to:04}") From 7ac5215f98459b046f8c4b93165a2bff4081b009 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Mon, 28 Oct 2024 17:15:05 +0000 Subject: [PATCH 2/5] Use datetime --- .../commands/rollbackmigrationsappliedafter.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/api/core/management/commands/rollbackmigrationsappliedafter.py b/api/core/management/commands/rollbackmigrationsappliedafter.py index a37a2e52e785..5e5af2696fd8 100644 --- a/api/core/management/commands/rollbackmigrationsappliedafter.py +++ b/api/core/management/commands/rollbackmigrationsappliedafter.py @@ -1,5 +1,5 @@ from argparse import ArgumentParser -from datetime import datetime, timezone +from datetime import datetime from django.core.management import BaseCommand, CommandError, call_command from django.db.migrations.recorder import MigrationRecorder @@ -7,24 +7,24 @@ class Command(BaseCommand): """ - Rollback all migrations applied on or after a given date. + Rollback all migrations applied on or after a given datetime. - Usage: python manage.py rollbackmigrationsappliedafter --date 2024-10-24 + Usage: python manage.py rollbackmigrationsappliedafter --datetime 2024-10-24 """ def add_arguments(self, parser: ArgumentParser): parser.add_argument( - "--date", + "--datetime", type=str, required=True, - help="Rollback all migrations applied on or after this UTC date (provided in the format YYYY-MM-DD)", + help="Rollback all migrations applied on or after this datetime (provided in ISO format)", ) def handle(self, *args, date: str, **kwargs) -> None: try: - _date = datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc) + _date = datetime.fromisoformat(date) except ValueError: - raise CommandError("Date must be in format YYYY-MM-DD") + raise CommandError("Date must be in ISO format") applied_migrations = MigrationRecorder.Migration.objects.filter(applied__gte=_date) for migration in applied_migrations: From d4c909333dce7cca23aa3beac48332ba6a5a601c Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 30 Oct 2024 15:11:42 +0000 Subject: [PATCH 3/5] Add test coverage --- .../rollbackmigrationsappliedafter.py | 29 ++++-- .../unit/core/test_unit_core_management.py | 92 +++++++++++++++++++ 2 files changed, 112 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit/core/test_unit_core_management.py diff --git a/api/core/management/commands/rollbackmigrationsappliedafter.py b/api/core/management/commands/rollbackmigrationsappliedafter.py index 5e5af2696fd8..73b01a8b7a3f 100644 --- a/api/core/management/commands/rollbackmigrationsappliedafter.py +++ b/api/core/management/commands/rollbackmigrationsappliedafter.py @@ -9,25 +9,36 @@ class Command(BaseCommand): """ Rollback all migrations applied on or after a given datetime. - Usage: python manage.py rollbackmigrationsappliedafter --datetime 2024-10-24 + Usage: python manage.py rollbackmigrationsappliedafter "2024-10-24 08:23:45" """ def add_arguments(self, parser: ArgumentParser): parser.add_argument( - "--datetime", + "dt", type=str, - required=True, help="Rollback all migrations applied on or after this datetime (provided in ISO format)", ) - def handle(self, *args, date: str, **kwargs) -> None: + def handle(self, *args, dt: str, **kwargs) -> None: try: - _date = datetime.fromisoformat(date) + _dt = datetime.fromisoformat(dt) except ValueError: raise CommandError("Date must be in ISO format") - applied_migrations = MigrationRecorder.Migration.objects.filter(applied__gte=_date) + applied_migrations = MigrationRecorder.Migration.objects.filter(applied__gte=_dt).order_by("applied") + if not applied_migrations.exists(): + self.stdout.write(self.style.NOTICE("No migrations to rollback.")) + + # Since we've ordered by the date applied, we know that the first entry in the qs for each app + # is the earliest migration after the supplied date, i.e. we want to roll back to one migration + # earlier than this one. + earliest_migration_by_app = {} for migration in applied_migrations: - migration_number = migration.name.split("_", maxsplit=1)[0] - rollback_to = int(migration_number) - 1 - call_command("migrate", migration.app, f"{rollback_to:04}") + if migration.app in earliest_migration_by_app: + continue + earliest_migration_by_app[migration.app] = migration.name + + for app, migration_name in earliest_migration_by_app.items(): + migration_number = int(migration_name.split("_", maxsplit=1)[0]) + rollback_to = f"{migration_number - 1:04}" if migration_number > 1 else "zero" + call_command("migrate", app, rollback_to) diff --git a/api/tests/unit/core/test_unit_core_management.py b/api/tests/unit/core/test_unit_core_management.py new file mode 100644 index 000000000000..8f4c023e5ab0 --- /dev/null +++ b/api/tests/unit/core/test_unit_core_management.py @@ -0,0 +1,92 @@ +from unittest.mock import call + +import pytest +from _pytest.capture import CaptureFixture +from django.core.management import CommandError, call_command +from django.db.migrations.recorder import MigrationRecorder +from pytest_mock import MockerFixture + + +class MockQuerySet(list): + def exists(self) -> bool: + return self.__len__() > 0 + + +def test_rollbackmigrationsappliedafter(mocker: MockerFixture) -> None: + # Given + dt_string = "2024-10-24 08:23:45" + + migration_1 = mocker.MagicMock(app="foo", spec=MigrationRecorder.Migration) + migration_1.name = "0001_initial" + + migration_2 = mocker.MagicMock(app="bar", spec=MigrationRecorder.Migration) + migration_2.name = "0002_some_migration_description" + + migration_3 = mocker.MagicMock(app="bar", spec=MigrationRecorder.Migration) + migration_3.name = "0003_some_other_migration_description" + + migrations = MockQuerySet([migration_1, migration_2, migration_3]) + + mocked_migration_recorder = mocker.patch( + "core.management.commands.rollbackmigrationsappliedafter.MigrationRecorder" + ) + mocked_migration_recorder.Migration.objects.filter.return_value.order_by.return_value = ( + migrations + ) + + mocked_call_command = mocker.patch( + "core.management.commands.rollbackmigrationsappliedafter.call_command" + ) + + # When + call_command("rollbackmigrationsappliedafter", dt_string) + + # Then + assert mocked_call_command.mock_calls == [ + call("migrate", "foo", "zero"), + call("migrate", "bar", "0001"), + ] + + +def test_rollbackmigrationsappliedafter_invalid_date(mocker: MockerFixture) -> None: + # Given + dt_string = "foo" + + mocked_call_command = mocker.patch( + "core.management.commands.rollbackmigrationsappliedafter.call_command" + ) + + # When + with pytest.raises(CommandError) as e: + call_command("rollbackmigrationsappliedafter", dt_string) + + # Then + assert mocked_call_command.mock_calls == [] + assert e.value.args == ("Date must be in ISO format",) + + +def test_rollbackmigrationsappliedafter_no_migrations( + mocker: MockerFixture, capsys: CaptureFixture +) -> None: + # Given + dt_string = "2024-10-01" + + mocked_migration_recorder = mocker.patch( + "core.management.commands.rollbackmigrationsappliedafter.MigrationRecorder" + ) + mocked_migration_recorder.Migration.objects.filter.return_value.order_by.return_value = MockQuerySet( + [] + ) + + mocked_call_command = mocker.patch( + "core.management.commands.rollbackmigrationsappliedafter.call_command" + ) + + # When + call_command("rollbackmigrationsappliedafter", dt_string) + + # Then + assert mocked_call_command.mock_calls == [] + + captured = capsys.readouterr() + assert captured.out == "No migrations to rollback.\n" From fa90433d32c68beab548ecaeb1d41759953b2a27 Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 30 Oct 2024 15:17:08 +0000 Subject: [PATCH 4/5] Refactoring for tidiness --- .../rollbackmigrationsappliedafter.py | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/api/core/management/commands/rollbackmigrationsappliedafter.py b/api/core/management/commands/rollbackmigrationsappliedafter.py index 73b01a8b7a3f..f0473d91dd27 100644 --- a/api/core/management/commands/rollbackmigrationsappliedafter.py +++ b/api/core/management/commands/rollbackmigrationsappliedafter.py @@ -30,8 +30,7 @@ def handle(self, *args, dt: str, **kwargs) -> None: self.stdout.write(self.style.NOTICE("No migrations to rollback.")) # Since we've ordered by the date applied, we know that the first entry in the qs for each app - # is the earliest migration after the supplied date, i.e. we want to roll back to one migration - # earlier than this one. + # is the earliest migration after the supplied date. earliest_migration_by_app = {} for migration in applied_migrations: if migration.app in earliest_migration_by_app: @@ -39,6 +38,21 @@ def handle(self, *args, dt: str, **kwargs) -> None: earliest_migration_by_app[migration.app] = migration.name for app, migration_name in earliest_migration_by_app.items(): - migration_number = int(migration_name.split("_", maxsplit=1)[0]) - rollback_to = f"{migration_number - 1:04}" if migration_number > 1 else "zero" - call_command("migrate", app, rollback_to) + call_command( + "migrate", app, _get_previous_migration_number(migration_name) + ) + + +def _get_previous_migration_number(migration_name: str) -> str: + """ + Returns the previous migration number (0 padded number to 4 characters), or zero + if the provided migration name is the first for a given app (usually 0001_initial). + + Examples: + _get_previous_migration_number("0001_initial") -> "zero" + _get_previous_migration_number("0009_migration_9") -> "0008" + _get_previous_migration_number("0103_migration_103") -> "0102" + """ + + migration_number = int(migration_name.split("_", maxsplit=1)[0]) + return f"{migration_number - 1:04}" if migration_number > 1 else "zero" From b5852da619c16df4e075b37de5fb00db5f6e05ef Mon Sep 17 00:00:00 2001 From: Matthew Elwell Date: Wed, 30 Oct 2024 15:22:19 +0000 Subject: [PATCH 5/5] Update docs --- docs/docs/deployment/hosting/locally-api.md | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/docs/deployment/hosting/locally-api.md b/docs/docs/deployment/hosting/locally-api.md index d2b051883ab9..6f080e5cc406 100644 --- a/docs/docs/deployment/hosting/locally-api.md +++ b/docs/docs/deployment/hosting/locally-api.md @@ -477,7 +477,20 @@ ORDER BY applied DESC ::: -2. Replace the datetime in the query below with a datetime after the deployment of the version you want to roll back to, +2. Run the following command inside a Flagsmith API container running the _current_ version of Flagsmith + +```bash +python manage.py rollbackmigrationsafter "" +``` + +3. Roll back the Flagsmith API to the desired version. + +### Steps pre v2.151.0 + +If you are rolling back from a version earlier than v2.151.0, you will need to replace step 2 above with the following 2 +steps. + +1. Replace the datetime in the query below with a datetime after the deployment of the version you want to roll back to, and before any subsequent deployments. Execute the subsequent query against the Flagsmith database. ```sql {14} showLineNumbers @@ -511,8 +524,7 @@ Example output: python manage.py migrate token_blacklist zero ``` -3. Run the generated commands inside a Flagsmith API container running the _current_ version of Flagsmith -4. Roll back the Flagsmith API to the desired version. +2. Run the generated commands inside a Flagsmith API container running the _current_ version of Flagsmith ## Information for Developers working on the project