Skip to content

Commit 2ac304d

Browse files
allow pytest --migrations to succeed (#14663)
* allow pytest --migrations to succeed * We actually subvert migrations from running in test via pytest.ini --no-migrations option. This has led to bit rot for the sqlite migrations happy path. This changeset pays off that tech debt and allows for an sqlite migration happy path. * This paves the way for programatic invocation of individual migrations and weaving of the creation of resources (i.e. Instance, Job Template, etc). With this, a developer can instantiate various database states, trigger a migration, assert the state of the db, and then have pytest rollback all of that. * I will note that in practice, running these migrations is dog shit slow BUT this work also opens up the possibility of saving and re-using sqlite3 database files. Normally, caching is not THE answer and causes more harm than good. But in this case, our migrations are mostly write-once (I say mostly because this change set violates that :) so cache invalidation isn't a major issue. * functional test for migrations on sqlite * We commonly subvert running migrations in test land. Test land uses sqlite. By not constantly exercising this code path it atrophies. The smoke test here is to continuously exercise that code path. * Add ci test to run migration tests separately, they take =~ 2-3 minutes each on my laptop. * The smoke tests also serves as an example of how to write migration tests. * run migration tests in ci
1 parent 3e5851f commit 2ac304d

File tree

11 files changed

+177
-30
lines changed

11 files changed

+177
-30
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ jobs:
2020
tests:
2121
- name: api-test
2222
command: /start_tests.sh
23+
- name: api-migrations
24+
command: /start_tests.sh test_migrations
2325
- name: api-lint
2426
command: /var/lib/awx/venv/awx/bin/tox -e linters
2527
- name: api-swagger

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,12 @@ test:
324324
cd awxkit && $(VENV_BASE)/awx/bin/tox -re py3
325325
awx-manage check_migrations --dry-run --check -n 'missing_migration_file'
326326

327+
test_migrations:
328+
if [ "$(VENV_BASE)" ]; then \
329+
. $(VENV_BASE)/awx/bin/activate; \
330+
fi; \
331+
PYTHONDONTWRITEBYTECODE=1 py.test -p no:cacheprovider --migrations -m migration_test $(PYTEST_ARGS) $(TEST_DIRS)
332+
327333
## Runs AWX_DOCKER_CMD inside a new docker container.
328334
docker-runner:
329335
docker run -u $(shell id -u) --rm -v $(shell pwd):/awx_devel/:Z --workdir=/awx_devel $(DEVEL_IMAGE_NAME) $(AWX_DOCKER_CMD)

awx/main/migrations/0006_v320_release.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
# AWX
1010
import awx.main.fields
1111
from awx.main.models import Host
12+
from ._sqlite_helper import dbawaremigrations
1213

1314

1415
def replaces():
@@ -131,9 +132,11 @@ class Migration(migrations.Migration):
131132
help_text='If enabled, Tower will act as an Ansible Fact Cache Plugin; persisting facts at the end of a playbook run to the database and caching facts for use by Ansible.',
132133
),
133134
),
134-
migrations.RunSQL(
135+
dbawaremigrations.RunSQL(
135136
sql="CREATE INDEX host_ansible_facts_default_gin ON {} USING gin(ansible_facts jsonb_path_ops);".format(Host._meta.db_table),
136137
reverse_sql='DROP INDEX host_ansible_facts_default_gin;',
138+
sqlite_sql=dbawaremigrations.RunSQL.noop,
139+
sqlite_reverse_sql=dbawaremigrations.RunSQL.noop,
137140
),
138141
# SCM file-based inventories
139142
migrations.AddField(

awx/main/migrations/0050_v340_drop_celery_tables.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,27 @@
33

44
from django.db import migrations
55

6+
from ._sqlite_helper import dbawaremigrations
7+
8+
tables_to_drop = [
9+
'celery_taskmeta',
10+
'celery_tasksetmeta',
11+
'djcelery_crontabschedule',
12+
'djcelery_intervalschedule',
13+
'djcelery_periodictask',
14+
'djcelery_periodictasks',
15+
'djcelery_taskstate',
16+
'djcelery_workerstate',
17+
'djkombu_message',
18+
'djkombu_queue',
19+
]
20+
postgres_sql = ([("DROP TABLE IF EXISTS {} CASCADE;".format(table))] for table in tables_to_drop)
21+
sqlite_sql = ([("DROP TABLE IF EXISTS {};".format(table))] for table in tables_to_drop)
22+
623

724
class Migration(migrations.Migration):
825
dependencies = [
926
('main', '0049_v330_validate_instance_capacity_adjustment'),
1027
]
1128

12-
operations = [
13-
migrations.RunSQL([("DROP TABLE IF EXISTS {} CASCADE;".format(table))])
14-
for table in (
15-
'celery_taskmeta',
16-
'celery_tasksetmeta',
17-
'djcelery_crontabschedule',
18-
'djcelery_intervalschedule',
19-
'djcelery_periodictask',
20-
'djcelery_periodictasks',
21-
'djcelery_taskstate',
22-
'djcelery_workerstate',
23-
'djkombu_message',
24-
'djkombu_queue',
25-
)
26-
]
29+
operations = [dbawaremigrations.RunSQL(p, sqlite_sql=s) for p, s in zip(postgres_sql, sqlite_sql)]

awx/main/migrations/0113_v370_event_bigint.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from django.db import migrations, models, connection
44

5+
from ._sqlite_helper import dbawaremigrations
6+
57

68
def migrate_event_data(apps, schema_editor):
79
# see: https://github.com/ansible/awx/issues/6010
@@ -24,6 +26,11 @@ def migrate_event_data(apps, schema_editor):
2426
cursor.execute(f'ALTER TABLE {tblname} ALTER COLUMN id TYPE bigint USING id::bigint;')
2527

2628

29+
def migrate_event_data_sqlite(apps, schema_editor):
30+
# TODO: cmeyers fill this in
31+
return
32+
33+
2734
class FakeAlterField(migrations.AlterField):
2835
def database_forwards(self, *args):
2936
# this is intentionally left blank, because we're
@@ -37,7 +44,7 @@ class Migration(migrations.Migration):
3744
]
3845

3946
operations = [
40-
migrations.RunPython(migrate_event_data),
47+
dbawaremigrations.RunPython(migrate_event_data, sqlite_code=migrate_event_data_sqlite),
4148
FakeAlterField(
4249
model_name='adhoccommandevent',
4350
name='id',

awx/main/migrations/0144_event_partitions.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from django.db import migrations, models, connection
22

3+
from ._sqlite_helper import dbawaremigrations
4+
35

46
def migrate_event_data(apps, schema_editor):
57
# see: https://github.com/ansible/awx/issues/9039
@@ -59,6 +61,10 @@ def migrate_event_data(apps, schema_editor):
5961
cursor.execute('DROP INDEX IF EXISTS main_jobevent_job_id_idx')
6062

6163

64+
def migrate_event_data_sqlite(apps, schema_editor):
65+
return None
66+
67+
6268
class FakeAddField(migrations.AddField):
6369
def database_forwards(self, *args):
6470
# this is intentionally left blank, because we're
@@ -72,7 +78,7 @@ class Migration(migrations.Migration):
7278
]
7379

7480
operations = [
75-
migrations.RunPython(migrate_event_data),
81+
dbawaremigrations.RunPython(migrate_event_data, sqlite_code=migrate_event_data_sqlite),
7682
FakeAddField(
7783
model_name='jobevent',
7884
name='job_created',

awx/main/migrations/0185_move_JSONBlob_to_JSONField.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import awx.main.models.notifications
44
from django.db import migrations, models
55

6+
from ._sqlite_helper import dbawaremigrations
7+
68

79
class Migration(migrations.Migration):
810
dependencies = [
@@ -104,11 +106,12 @@ class Migration(migrations.Migration):
104106
name='deleted_actor',
105107
field=models.JSONField(null=True),
106108
),
107-
migrations.RunSQL(
109+
dbawaremigrations.RunSQL(
108110
"""
109111
ALTER TABLE main_activitystream RENAME setting TO setting_old;
110112
ALTER TABLE main_activitystream ALTER COLUMN setting_old DROP NOT NULL;
111113
""",
114+
sqlite_sql="ALTER TABLE main_activitystream RENAME setting TO setting_old",
112115
state_operations=[
113116
migrations.RemoveField(
114117
model_name='activitystream',
@@ -121,11 +124,12 @@ class Migration(migrations.Migration):
121124
name='setting',
122125
field=models.JSONField(blank=True, default=dict),
123126
),
124-
migrations.RunSQL(
127+
dbawaremigrations.RunSQL(
125128
"""
126129
ALTER TABLE main_job RENAME survey_passwords TO survey_passwords_old;
127130
ALTER TABLE main_job ALTER COLUMN survey_passwords_old DROP NOT NULL;
128131
""",
132+
sqlite_sql="ALTER TABLE main_job RENAME survey_passwords TO survey_passwords_old",
129133
state_operations=[
130134
migrations.RemoveField(
131135
model_name='job',
@@ -138,11 +142,12 @@ class Migration(migrations.Migration):
138142
name='survey_passwords',
139143
field=models.JSONField(blank=True, default=dict, editable=False),
140144
),
141-
migrations.RunSQL(
145+
dbawaremigrations.RunSQL(
142146
"""
143147
ALTER TABLE main_joblaunchconfig RENAME char_prompts TO char_prompts_old;
144148
ALTER TABLE main_joblaunchconfig ALTER COLUMN char_prompts_old DROP NOT NULL;
145149
""",
150+
sqlite_sql="ALTER TABLE main_joblaunchconfig RENAME char_prompts TO char_prompts_old",
146151
state_operations=[
147152
migrations.RemoveField(
148153
model_name='joblaunchconfig',
@@ -155,11 +160,12 @@ class Migration(migrations.Migration):
155160
name='char_prompts',
156161
field=models.JSONField(blank=True, default=dict),
157162
),
158-
migrations.RunSQL(
163+
dbawaremigrations.RunSQL(
159164
"""
160165
ALTER TABLE main_joblaunchconfig RENAME survey_passwords TO survey_passwords_old;
161166
ALTER TABLE main_joblaunchconfig ALTER COLUMN survey_passwords_old DROP NOT NULL;
162167
""",
168+
sqlite_sql="ALTER TABLE main_joblaunchconfig RENAME survey_passwords TO survey_passwords_old;",
163169
state_operations=[
164170
migrations.RemoveField(
165171
model_name='joblaunchconfig',
@@ -172,11 +178,12 @@ class Migration(migrations.Migration):
172178
name='survey_passwords',
173179
field=models.JSONField(blank=True, default=dict, editable=False),
174180
),
175-
migrations.RunSQL(
181+
dbawaremigrations.RunSQL(
176182
"""
177183
ALTER TABLE main_notification RENAME body TO body_old;
178184
ALTER TABLE main_notification ALTER COLUMN body_old DROP NOT NULL;
179185
""",
186+
sqlite_sql="ALTER TABLE main_notification RENAME body TO body_old",
180187
state_operations=[
181188
migrations.RemoveField(
182189
model_name='notification',
@@ -189,11 +196,12 @@ class Migration(migrations.Migration):
189196
name='body',
190197
field=models.JSONField(blank=True, default=dict),
191198
),
192-
migrations.RunSQL(
199+
dbawaremigrations.RunSQL(
193200
"""
194201
ALTER TABLE main_unifiedjob RENAME job_env TO job_env_old;
195202
ALTER TABLE main_unifiedjob ALTER COLUMN job_env_old DROP NOT NULL;
196203
""",
204+
sqlite_sql="ALTER TABLE main_unifiedjob RENAME job_env TO job_env_old",
197205
state_operations=[
198206
migrations.RemoveField(
199207
model_name='unifiedjob',
@@ -206,11 +214,12 @@ class Migration(migrations.Migration):
206214
name='job_env',
207215
field=models.JSONField(blank=True, default=dict, editable=False),
208216
),
209-
migrations.RunSQL(
217+
dbawaremigrations.RunSQL(
210218
"""
211219
ALTER TABLE main_workflowjob RENAME char_prompts TO char_prompts_old;
212220
ALTER TABLE main_workflowjob ALTER COLUMN char_prompts_old DROP NOT NULL;
213221
""",
222+
sqlite_sql="ALTER TABLE main_workflowjob RENAME char_prompts TO char_prompts_old",
214223
state_operations=[
215224
migrations.RemoveField(
216225
model_name='workflowjob',
@@ -223,11 +232,12 @@ class Migration(migrations.Migration):
223232
name='char_prompts',
224233
field=models.JSONField(blank=True, default=dict),
225234
),
226-
migrations.RunSQL(
235+
dbawaremigrations.RunSQL(
227236
"""
228237
ALTER TABLE main_workflowjob RENAME survey_passwords TO survey_passwords_old;
229238
ALTER TABLE main_workflowjob ALTER COLUMN survey_passwords_old DROP NOT NULL;
230239
""",
240+
sqlite_sql="ALTER TABLE main_workflowjob RENAME survey_passwords TO survey_passwords_old",
231241
state_operations=[
232242
migrations.RemoveField(
233243
model_name='workflowjob',
@@ -240,11 +250,12 @@ class Migration(migrations.Migration):
240250
name='survey_passwords',
241251
field=models.JSONField(blank=True, default=dict, editable=False),
242252
),
243-
migrations.RunSQL(
253+
dbawaremigrations.RunSQL(
244254
"""
245255
ALTER TABLE main_workflowjobnode RENAME char_prompts TO char_prompts_old;
246256
ALTER TABLE main_workflowjobnode ALTER COLUMN char_prompts_old DROP NOT NULL;
247257
""",
258+
sqlite_sql="ALTER TABLE main_workflowjobnode RENAME char_prompts TO char_prompts_old",
248259
state_operations=[
249260
migrations.RemoveField(
250261
model_name='workflowjobnode',
@@ -257,11 +268,12 @@ class Migration(migrations.Migration):
257268
name='char_prompts',
258269
field=models.JSONField(blank=True, default=dict),
259270
),
260-
migrations.RunSQL(
271+
dbawaremigrations.RunSQL(
261272
"""
262273
ALTER TABLE main_workflowjobnode RENAME survey_passwords TO survey_passwords_old;
263274
ALTER TABLE main_workflowjobnode ALTER COLUMN survey_passwords_old DROP NOT NULL;
264275
""",
276+
sqlite_sql="ALTER TABLE main_workflowjobnode RENAME survey_passwords TO survey_passwords_old",
265277
state_operations=[
266278
migrations.RemoveField(
267279
model_name='workflowjobnode',

awx/main/migrations/0186_drop_django_taggit.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
from django.db import migrations
55

6+
from ._sqlite_helper import dbawaremigrations
7+
68

79
def delete_taggit_contenttypes(apps, schema_editor):
810
ContentType = apps.get_model('contenttypes', 'ContentType')
@@ -20,8 +22,8 @@ class Migration(migrations.Migration):
2022
]
2123

2224
operations = [
23-
migrations.RunSQL("DROP TABLE IF EXISTS taggit_tag CASCADE;"),
24-
migrations.RunSQL("DROP TABLE IF EXISTS taggit_taggeditem CASCADE;"),
25+
dbawaremigrations.RunSQL("DROP TABLE IF EXISTS taggit_tag CASCADE;", sqlite_sql="DROP TABLE IF EXISTS taggit_tag;"),
26+
dbawaremigrations.RunSQL("DROP TABLE IF EXISTS taggit_taggeditem CASCADE;", sqlite_sql="DROP TABLE IF EXISTS taggit_taggeditem;"),
2527
migrations.RunPython(delete_taggit_contenttypes),
2628
migrations.RunPython(delete_taggit_migration_records),
2729
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from django.db import migrations
2+
3+
4+
class RunSQL(migrations.operations.special.RunSQL):
5+
"""
6+
Bit of a hack here. Django actually wants this decision made in the router
7+
and we can pass **hints.
8+
"""
9+
10+
def __init__(self, *args, **kwargs):
11+
if 'sqlite_sql' not in kwargs:
12+
raise ValueError("sqlite_sql parameter required")
13+
sqlite_sql = kwargs.pop('sqlite_sql')
14+
15+
self.sqlite_sql = sqlite_sql
16+
self.sqlite_reverse_sql = kwargs.pop('sqlite_reverse_sql', None)
17+
super().__init__(*args, **kwargs)
18+
19+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
20+
if not schema_editor.connection.vendor.startswith('postgres'):
21+
self.sql = self.sqlite_sql or migrations.RunSQL.noop
22+
super().database_forwards(app_label, schema_editor, from_state, to_state)
23+
24+
def database_backwards(self, app_label, schema_editor, from_state, to_state):
25+
if not schema_editor.connection.vendor.startswith('postgres'):
26+
self.reverse_sql = self.sqlite_reverse_sql or migrations.RunSQL.noop
27+
super().database_backwards(app_label, schema_editor, from_state, to_state)
28+
29+
30+
class RunPython(migrations.operations.special.RunPython):
31+
"""
32+
Bit of a hack here. Django actually wants this decision made in the router
33+
and we can pass **hints.
34+
"""
35+
36+
def __init__(self, *args, **kwargs):
37+
if 'sqlite_code' not in kwargs:
38+
raise ValueError("sqlite_code parameter required")
39+
sqlite_code = kwargs.pop('sqlite_code')
40+
41+
self.sqlite_code = sqlite_code
42+
self.sqlite_reverse_code = kwargs.pop('sqlite_reverse_code', None)
43+
super().__init__(*args, **kwargs)
44+
45+
def database_forwards(self, app_label, schema_editor, from_state, to_state):
46+
if not schema_editor.connection.vendor.startswith('postgres'):
47+
self.code = self.sqlite_code or migrations.RunPython.noop
48+
super().database_forwards(app_label, schema_editor, from_state, to_state)
49+
50+
def database_backwards(self, app_label, schema_editor, from_state, to_state):
51+
if not schema_editor.connection.vendor.startswith('postgres'):
52+
self.reverse_code = self.sqlite_reverse_code or migrations.RunPython.noop
53+
super().database_backwards(app_label, schema_editor, from_state, to_state)
54+
55+
56+
class _sqlitemigrations:
57+
RunPython = RunPython
58+
RunSQL = RunSQL
59+
60+
61+
dbawaremigrations = _sqlitemigrations()

0 commit comments

Comments
 (0)