Skip to content

Commit da5906e

Browse files
committed
log improvements, some rework, update readme
1 parent 03ec91e commit da5906e

File tree

6 files changed

+265
-154
lines changed

6 files changed

+265
-154
lines changed

README.md

Lines changed: 67 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ This package allow to easy manage apps migrations based on GIT repository (it us
33
So you can return to any previous state that is saved in DB by one command.
44

55
## Version
6-
Current version is `0.3.1`
6+
Current version is `0.4.0`
77

88
It works with:
99
- django >= 1.11.3
@@ -14,7 +14,7 @@ It works with:
1414

1515
First you need to install package with pip:
1616
```bash
17-
pip install git+https://github.com/freenoth/django-rollback.git@0.3.1
17+
pip install git+https://github.com/freenoth/django-rollback.git@0.4.0
1818
```
1919

2020
Then install it to your `INSTALLED_APPS`
@@ -39,7 +39,7 @@ Both commands have common arguments:
3939
Log level for logging. INFO, DEBUG, etc.
4040

4141
```
42-
`PATH` argument used to specify path to git repository directory (local). Default path is current dir : `'.'`.
42+
`PATH` argument used to specify path to git repository directory (local). Default path is current dir : `'.'`. For django applications it is a project root where `manage.py` is located.
4343

4444
`LOGGER` and `LOG_LEVEL` arguments can be used to setup internal logging. For example, you can use one of django_logging loggers (to push it to slack, write console, file, etc.). There is no default value, so by default additional logging disabled.
4545

@@ -51,9 +51,10 @@ Help message below:
5151
```bash
5252
usage: manage.py save_migrations_state [-p PATH] [-l LOGGER]
5353
[--log-level LOG_LEVEL]
54+
[--log-full-data] [--log-diff]
5455

5556
Save migrations state for current commit. It also check if commit already
56-
exists and print warning if it`s not the latest state that may be a symptom of
57+
exists and print warning if it's not the latest state that may be a symptom of
5758
inconsistent state for migrations.
5859
5960
optional arguments:
@@ -62,24 +63,29 @@ optional arguments:
6263
Logger name for logging.
6364
--log-level LOG_LEVEL
6465
Log level for logging. INFO, DEBUG, etc.
66+
--log-full-data Log full data for migrations state when created.
67+
--log-diff Log current diff for current and previous state.
6568
6669
```
6770
This command used to save apps migrations state of current commit to DB. It try to create new state, but if already exists it checks is this state the latest.
6871
If state for current commit is not the latest - it may be a symptom of problems and rollback from current commit will not work for it.
6972
7073
Successful output example below:
7174
```bash
72-
Data = [(4, 'admin', '0002_logentry_remove_auto_add'), (12, 'auth', '0008_alter_user_username_max_length'), (5, 'contenttypes', '0002_remove_content_type_name'), (16, 'django_auto_rollback', '0001_initial'), (17, 'django_rollback', '0001_initial'), (14, 'sessions', '0001_initial')].
73-
Successfully created for commit "84e47461a95fa325d9e933bbe8cca8c52bbea203".
75+
$ ./manage.py save_migrations_state --log-full-data --log-diff
76+
State successfully created for commit "03ec91e5319ed65a94f8ea07f6093018a61f9e1b" ['0.3.2'].
77+
Data = [(4, 'admin', '0002_logentry_remove_auto_add'), (12, 'auth', '0008_alter_user_username_max_length'), (5, 'contenttypes', '0002_remove_content_type_name'), (15, 'django_rollback', '0001_initial'), (14, 'sessions', '0001_initial')]
78+
Diff not found. There is no migrations to rollback.
7479
```
80+
Every commit that is logged will be marked by list of tags for this commit.
7581
7682
### Return to previous state (rollback)
7783
```bash
7884
./manage.py rollback_migrations
7985
```
8086
Help message below:
8187
```bash
82-
usage: manage.py rollback_migrations [-p PATH] [-l LOGGER] [--log-level LOG_LEVEL]
88+
usage: manage.py rollback_migrations [-p PATH] [-l LOGGER] [--log-level LOG_LEVEL]
8389
[--list] [-t TAG] [-c COMMIT] [--fake]
8490
8591
Rollback migrations state of all django apps to chosen tag or commit if
@@ -100,6 +106,7 @@ optional arguments:
100106
Git commit hash to which to rollback migrations.
101107
--fake It allow to only print info about processed actions
102108
without execution (no changes for DB).
109+
103110
```
104111
105112
You can use git commit hash (hex) directly. And you don`t need to specify full commit hash, you just can use first letters.
@@ -117,14 +124,19 @@ Or you can use git tag (it will be translated to related commit).
117124
118125
Successful output example below:
119126
```bash
120-
./manage.py rollback_migrations -c c257a23
121-
>>> Executing command: migrate django_rollback zero
127+
$ ./manage.py rollback_migrations -c 0df07b
128+
Found migrations diff. In case of rollback need migrate to:
129+
[MigrationRecord(id=24, app='temp', name='zero')]
130+
Running rollback from commit "03ec91e5319ed65a94f8ea07f6093018a61f9e1b" ['0.2.1'] to commit "0df07b2f0ce8dbb9755cfd8a1b213f9c0735e833" ['0.2.0'].
131+
Executing command: `migrate temp zero`
122132
Operations to perform:
123-
Unapply all migrations: django_rollback
133+
Unapply all migrations: temp
124134
Running migrations:
125135
Rendering model states... DONE
126-
Unapplying django_rollback.0001_initial... OK
136+
Unapplying temp.0001_initial... OK
137+
state for commit "0df07b2f0ce8dbb9755cfd8a1b213f9c0735e833" ['0.2.0'] now is the last state in DB
127138
Rollback successfully finished.
139+
128140
```
129141
130142
As you can see above, apps can be rollbacked to `zero` state too, if in previous state this app not used.
@@ -137,3 +149,47 @@ So rollback will be successfully finished if two conditions are satisfied:
137149
- state for current commit was saved (if not - use `./manage.py save_migrations_state` command)
138150
- state for specified commit, commit which relates to specified tag was saved in the past
139151
- if commit not specified, just the previous state should exists
152+
153+
## Usage example
154+
For example, we have a django-application packed with Docker. We have a release cycle and deploy docker images based on some version of our source code. Building images includes copying all source code data (including `.git` directory), so `django_rollback` will have direct access to local git repository to identify or search commits, tags, etc.
155+
156+
Docker allows you to use some `entrypoint` that can be `.sh` file. So we have something like that:
157+
```dockerfile
158+
FROM python:3.6
159+
ENTRYPOINT ["dumb-init", "--"]
160+
CMD ["start.sh"]
161+
WORKDIR /src
162+
EXPOSE 8000
163+
164+
COPY . /src
165+
COPY docker/bin /usr/local/bin
166+
167+
### and other configuration
168+
```
169+
And our `start.sh` entrypoint can be something like this:
170+
```bash
171+
#!/bin/bash
172+
173+
./_manage.py migrate
174+
./_manage.py save_migrations_state -l "django.slack_logging" --log-level INFO --log-diff
175+
176+
gunicorn --bind 0.0.0.0:8000 -k eventlet -w $WORKER_COUNT --max-requests $MAX_REQUESTS --reload app.wsgi:application
177+
178+
```
179+
So every time when your application is starting in docker - current migrations state will be saved or checked. And you will see all actions with specified logger (slack channel, for example). So, now you have an ability to monitoring current migrations state of your django-application in real time.
180+
181+
### When something goes wrong...
182+
183+
With our release cycle sometimes something goes wrong during deployment of new version of our application and we want to bring back previous state of application.
184+
185+
So we need to unapply all new migrations and deploy docker image based on previous version.
186+
187+
You just can use some `.sh` script to run rollback migrations or manually run manage.py command using `docker run` or `docker exec` commands.
188+
189+
Script `rollback.sh` can be like this:
190+
```bash
191+
#!/usr/bin/env bash
192+
./_manage.py rollback_migrations "$@" -l "django.slack_logging" --log-level INFO
193+
194+
```
195+
So in case of rollback you also able to monitoring what`s going on: which migrations are unapplying and which version of source code new DB state corresponds.

django_rollback/management/base.py

Lines changed: 114 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import io
12
import json
23
import logging
4+
import traceback
35
from collections import namedtuple
46

57
import git
68
from django.core.management import call_command
79
from django.core.management.base import BaseCommand, CommandError
810
from django.db import connection
11+
from django.utils.encoding import force_str
912

1013
from django_rollback.consts import DEFAULT_REPO_PATH, COMMIT_MAX_LENGTH, MIGRATE_COMMAND
1114
from django_rollback.models import AppsState
@@ -18,13 +21,21 @@ class BaseRollbackCommand(BaseCommand):
1821

1922
def __init__(self, *args, **kwargs):
2023
super().__init__(*args, **kwargs)
24+
self._repo_path = DEFAULT_REPO_PATH
25+
self._repo_tags = None
26+
self._commits_info = {}
27+
self._out = io.StringIO()
2128
self._logger = None
29+
self._result_log_level = logging.DEBUG
2230

2331
def add_arguments(self, parser):
2432
parser.add_argument('-p', '--path', type=str, default=DEFAULT_REPO_PATH, help='Git repository path.')
2533
parser.add_argument('-l', '--logger', type=str, help='Logger name for logging.')
2634
parser.add_argument('--log-level', type=str, help='Log level for logging. INFO, DEBUG, etc.')
2735

36+
def configure_repo_path(self, options):
37+
self._repo_path = options.get('path', DEFAULT_REPO_PATH)
38+
2839
def configure_logger(self, options):
2940
if options['logger']:
3041
logger = logging.getLogger(options['logger'])
@@ -35,20 +46,97 @@ def configure_logger(self, options):
3546

3647
self._logger = logger
3748

49+
def add_log(self, message, style_func=None, ending='\n', log_level=logging.INFO, exc_info=False):
50+
if isinstance(message, str) and not message.endswith(ending):
51+
message += ending
52+
53+
if style_func is None:
54+
style_func = lambda x: x
55+
56+
if log_level > self._result_log_level:
57+
self._result_log_level = log_level
58+
59+
if exc_info:
60+
message += traceback.format_exc()
61+
if not message.endswith(ending):
62+
message += ending
63+
64+
self._out.write(force_str(style_func(message)))
65+
66+
def write_log(self):
67+
message = self._out.getvalue()
68+
69+
self.stdout.write(message)
70+
71+
if self._logger:
72+
self._logger.log(self._result_log_level, message)
73+
74+
def close_log(self):
75+
self._out.close()
76+
3877
def handle(self, *args, **options):
39-
raise NotImplemented
78+
try:
79+
self.configure_repo_path(options)
80+
self.configure_logger(options)
81+
self._handle(*args, **options)
4082

41-
def get_current_commit(self, path):
83+
finally:
84+
self.write_log()
85+
self.close_log()
86+
87+
def _handle(self, *args, **options):
88+
raise NotImplementedError('subclasses of BaseRollbackCommand must provide a _handle() method')
89+
90+
def get_current_commit(self):
4291
try:
43-
repo = git.Repo(path)
92+
repo = git.Repo(self._repo_path)
4493
return repo.head.commit.hexsha
4594
except ValueError as err:
46-
message = f'An error occurred while working with git repo!'
47-
self.stdout.write(self.style.ERROR(message))
48-
if self._logger:
49-
self._logger.error(message + f'\n{__name__}')
95+
self.add_log(f'An error occurred while working with git repo!', style_func=self.style.ERROR,
96+
log_level=logging.ERROR, exc_info=True)
5097
raise CommandError(err)
5198

99+
def get_previous_commit(self, raise_exception=True):
100+
"""
101+
current commit should be already validated
102+
so we are sure that the last state linked to current commit
103+
need to select previous commit
104+
"""
105+
106+
if AppsState.objects.count() < 2:
107+
message = f'There is only one state in DB. Can`t identify previous state. Rollback procedure impossible.'
108+
self.add_log(message, style_func=self.style.ERROR, log_level=logging.WARNING)
109+
if raise_exception:
110+
raise CommandError()
111+
112+
return None
113+
114+
return AppsState.objects.all().order_by('-timestamp')[1].commit
115+
116+
@property
117+
def repo_tags(self):
118+
if not self._repo_tags:
119+
try:
120+
repo = git.Repo(self._repo_path)
121+
result = {}
122+
for tag in repo.tags:
123+
result.setdefault(tag.commit.hexsha, [])
124+
result[tag.commit.hexsha].append(tag.name)
125+
126+
self._repo_tags = result
127+
128+
except Exception:
129+
message = f'An error occurred while working with git repo during getting Tags map.'
130+
self.add_log(message, style_func=self.style.WARNING)
131+
self._repo_tags = {}
132+
133+
return self._repo_tags
134+
135+
def get_commit_info(self, commit):
136+
if commit not in self._commits_info:
137+
self._commits_info[commit] = f'"{commit}" {self.repo_tags.get(commit, [])}'
138+
return self._commits_info[commit]
139+
52140
@staticmethod
53141
def get_last_apps_state():
54142
return AppsState.objects.all().order_by('timestamp').last()
@@ -69,21 +157,23 @@ def get_apps_state_by_commit(self, commit):
69157
count = queryset.count()
70158

71159
if count == 0:
72-
message = f'Can not find stored data of migrations state for commit `{commit}`.'
73-
if self._logger:
74-
self._logger.warning(message)
75-
raise CommandError(message)
160+
message = f'Cant find stored data of migrations state for commit {self.get_commit_info(commit)}.'
161+
self.add_log(message, style_func=self.style.ERROR, log_level=logging.WARNING)
162+
raise CommandError()
76163

77164
if count > 1:
78165
is_short_commit = len(commit) < COMMIT_MAX_LENGTH
79-
message = (f'Found more than 1 ({count}) records for selected commit {commit}.'
166+
message = (f'Found more than 1 ({count}) records for selected commit {self.get_commit_info(commit)}.'
80167
f'{" Please clarify commit hash for more identity." if is_short_commit else ""}')
81-
if self._logger:
82-
self._logger.warning(message)
83-
raise CommandError(message)
168+
self.add_log(message, style_func=self.style.ERROR, log_level=logging.WARNING)
169+
raise CommandError()
84170

85171
return queryset.first()
86172

173+
def search_commit(self, commit):
174+
apps_state = self.get_apps_state_by_commit(commit)
175+
return apps_state.commit
176+
87177
def get_migrations_data_by_commit(self, commit):
88178
apps_state = self.get_apps_state_by_commit(commit)
89179
return json.loads(apps_state.migrations)
@@ -117,9 +207,11 @@ def get_migrations_diff(self, current, other):
117207
'zero' if is_new_app else list(filter(lambda x: x.app == migration.app, other))[0].name,
118208
))
119209

120-
if self._logger:
121-
self._logger.info(f'Migrations diff:\n{result}')
122-
210+
if result:
211+
self.add_log(f'Found migrations diff. In case of rollback need migrate to:\n{result}',
212+
log_level=logging.WARNING)
213+
else:
214+
self.add_log(f'Diff not found. There is no migrations to rollback.')
123215
return result
124216

125217
def run_rollback(self, migrations_diff_records, fake=False):
@@ -129,27 +221,16 @@ def run_rollback(self, migrations_diff_records, fake=False):
129221
"""
130222

131223
if not migrations_diff_records:
132-
message = 'There is no migrations to rollback.'
133-
self.stdout.write(f'>>> {message}')
134-
if self._logger:
135-
self._logger.info(message)
224+
self.add_log('There is no migrations to rollback.')
136225
return
137226

138227
for migration in sorted(migrations_diff_records, key=lambda r: int(r.id), reverse=True):
139228
execute_args = (MIGRATE_COMMAND, migration.app, migration.name)
140-
message = f'Executing command: {" ".join(execute_args)}'
141-
self.stdout.write(f'>>> {message}')
142-
if self._logger:
143-
self._logger.info(message)
144-
229+
self.add_log(f'Executing command: `{" ".join(execute_args)}`' + (' (executing faked)' if fake else ''))
145230
if not fake:
146-
call_command(*execute_args)
231+
call_command(*execute_args, stdout=self._out)
147232

148233
def make_the_last_state_for_commit(self, commit):
149234
apps_state = self.get_apps_state_by_commit(commit)
150235
AppsState.objects.filter(id__gt=apps_state.id).delete()
151-
152-
message = f'state for commit "{commit}" now is the last state in DB'
153-
self.stdout.write(f'>>> {message}')
154-
if self._logger:
155-
self._logger.info(message)
236+
self.add_log(f'state for commit {self.get_commit_info(commit)} now is the last state in DB')

0 commit comments

Comments
 (0)