conftest.py 16.5 KB
Newer Older
1
# -*- coding: utf-8 -*-
2
import contextlib
3
import logging
4
import pytest
5
import threading
6
import time
7
import os
8

Georges Racinet's avatar
Georges Racinet committed
9
from tests.utils import unique_name
10
from tests.utils.project import Project
Georges Racinet's avatar
Georges Racinet committed
11
from tests.utils.group import Group
12
from tests.utils.runner import Runner
Georges Racinet's avatar
Georges Racinet committed
13
from tests.utils.user import User
14
from tests.utils import git
15
from tests.utils import hg
16
from tests.utils.heptapod import (
17
    Heptapod,
18
    OmnibusHeptapod,
19
20
    DockerHeptapod,
    SourceHeptapod,
21
    GdkHeptapod,
22
)
23

24
25
26
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

27

28
29
def pytest_addoption(parser):
    """Add command-line options for Heptapod host."""
30
31
32
33
34
    parser.addoption('--heptapod-source-install', action="store_true",
                     help="Test a source install. This means that Heptapod "
                     "is running on the same system, these tests will "
                     "perform direct subprocess calls and filesystem "
                     "manipulations.")
35
36
37
38
39
40
41
42
43
    parser.addoption('--heptapod-gdk', action="store_true",
                     help="Test a Heptapod running "
                     "within the GitLab Development Kit (GDK) on the "
                     "same system as the tests."
                     "The tests will perform direct service management "
                     "(start/stop), subprocess calls "
                     "and filesystem manipulations."
                     "This mode needs rbenv or equivalent to be active."
                     )
44
45
46
    parser.addoption('--heptapod-omnibus', action="store_true",
                     help="Test a Heptapod Omnibus by running the tests "
                     "as root on the same system ")
47
48
49
50
51
    parser.addoption('--heptapod-remote', action='store_true',
                     help="Test a remote server. This means that no "
                     "direct command nor file system access is possible "
                     "leading to skipping some tests and some teardown "
                     "reliability actions.")
52
53
54
    parser.addoption('--heptapod-hg-native', action='store_true',
                     help="Have all Mercurial projects created as 'native' "
                     "(HGitaly backed)")
55
56
57
58
    parser.addoption('--heptapod-repositories-root',
                     help="Root of the group/repository hierarchy. "
                     "This is mandatory for source installs and ignored "
                     "in Docker mode.")
59
60
61
    parser.addoption('--heptapod-gdk-root',
                     help="Root of the installation for GDK Heptapod.")

62
63
    parser.addoption('--heptapod-url', default='http://heptapod:81')
    parser.addoption('--heptapod-ssh-port', type=int, default=2022)
64
    parser.addoption('--heptapod-ssh-user', default='git')
65
    parser.addoption('--heptapod-docker-container', default='heptapod')
66
    parser.addoption('--heptapod-root-password', default='5iveL!fe')
67
68
69
70
    parser.addoption('--heptapod-reverse-call-host',
                     help="Network address that the Heptapod server "
                     "can use to initiate network connections to "
                     "the system running these tests.")
71
72
73
74
75
76
77
    parser.addoption('--heptapod-webdriver-remote-url',
                     help="URL to a Selenium RemoteWebDriver server. "
                     "For instance, for the standalone-chrome "
                     "docker image, it would be http://container:4444/wd/hub "
                     "Care must be taken that `heptapod-url` is "
                     "appropriate from *both* the RemoteWebDriver server "
                     "*and* the host running these tests")
78
79
80
81
82
83
84
85
    parser.addoption('--heptapod-wait-after-first-response',
                     type=int, default=0,
                     help="Time to wait after we got a first non error "
                     "response from the server. This is especially useful "
                     "after waiting while the instance was being "
                     "created from scratch and in constrained environments "
                     "(CI…). Without this, it can even happen that the "
                     "first test passes and the second fails")
86
87
88
89
90
    parser.addoption('--heptapod-hg-client-executable',
                     default='hg',
                     help="Path to the hg executable on the client side, "
                     "i.e., the one used for local repos that get pulled "
                     "and pushed from the Heptapod server.")
91
92
93
94
95
    parser.addoption('--heptapod-git-client-executable',
                     default='git',
                     help="Path to the git executable on the client side, "
                     "i.e., the one used for local repos that get pulled "
                     "and pushed from the Heptapod server.")
96
97


98
99
100
101
def pytest_configure(config):
    config.addinivalue_line("markers",
                            "fs_access: mark test as needing file system "
                            "access to Heptapod server (Docker or local).")
102
103
104
105
    config.addinivalue_line("markers",
                            "reverse_call: mark test as involving network "
                            "calls from the Heptapod server to the host "
                            "running these tests")
106
107
108
    config.addinivalue_line("markers",
                            "docker: mark test as needing to run against "
                            "Docker Heptapod servers")
109
110
111
112
    config.addinivalue_line("markers",
                            "services: mark test as needing services "
                            "management "
                            )
113
114
115
116
117
118
119
120
121
122
    # if we regroup the tests with separate fixtures for the two
    # modes, these two will become useless:
    config.addinivalue_line("markers",
                            "hg_git: mark test as running only if "
                            "session is *not* in hg-native mode"
                            )
    config.addinivalue_line("markers",
                            "hg_native: mark test as running only if "
                            "session is in hg-native mode"
                            )
123
124


125
126
def pytest_collection_modifyitems(config, items):
    skip_fs_needed = pytest.mark.skip(reason="needs filesystem access")
127
128
    skip_docker = pytest.mark.skip(
        reason="needs Heptapod as a Docker container")
129
130
131
    skip_reverse_call = pytest.mark.skip(
        reason="needs to be able to initiate network connections to the host "
        "running these tests")
132
133
    skip_services = pytest.mark.skip(
        reason="needs to manage Heptapod services")
134
135
    skip_hg_git = pytest.mark.skip(reason="needs non hg-native mode")
    skip_hg_native = pytest.mark.skip(reason="needs hg-native mode")
136

137
    no_reverse_call = not(config.getoption('heptapod_reverse_call_host'))
138
    remote = config.getoption('heptapod_remote')
139
    source_install = config.getoption('heptapod_source_install')
140
    gdk = config.getoption('heptapod_gdk')
141
142
    omnibus = config.getoption('heptapod_omnibus')
    docker = not (gdk or source_install or remote or omnibus)
143
    hg_native = config.getoption("heptapod_hg_native")
144

145
    for item in items:
146
        if remote and "fs_access" in item.keywords:
147
            item.add_marker(skip_fs_needed)
148
        if "docker" in item.keywords and not docker:
149
            item.add_marker(skip_docker)
150
151
        if "reverse_call" in item.keywords and no_reverse_call:
            item.add_marker(skip_reverse_call)
152
153
        if "services" in item.keywords and (remote or source_install):
            item.add_marker(skip_services)
154
155
156
157
        if "hg_git" in item.keywords and hg_native:
            item.add_marker(skip_hg_git)
        if "hg_native" in item.keywords and not hg_native:
            item.add_marker(skip_hg_native)
158
159


160
161
162
163
164
heptapod_instance = None
lock = threading.Lock()
active_threads = 0


Georges Racinet's avatar
Georges Racinet committed
165
@pytest.fixture(scope="session")
166
def heptapod(pytestconfig):
167
168
169
170
171
172
173
    global lock
    global active_threads
    global heptapod_instance

    lock.acquire()
    active_threads += 1

174
175
176
177
178
    # Setting Git and Mercurial client-side executables.
    # This looks hacky as any monkey-patching does, but the alternative would
    # be to forward the pytestconfig fixture to pretty much all tests
    # or to store on the Heptapod object (all tests use it), which creates
    # confusion between the client and server sides.
179
    hg.HG_EXECUTABLE = pytestconfig.getoption('heptapod_hg_client_executable')
180
181
    git.GIT_EXECUTABLE = pytestconfig.getoption(
        'heptapod_git_client_executable')
182

183
    url = pytestconfig.getoption('heptapod_url')
184
    common = dict(
185
        url=url,
186
        ssh_user=pytestconfig.getoption('heptapod_ssh_user'),
187
        ssh_port=pytestconfig.getoption('heptapod_ssh_port'),
188
189
        reverse_call_host=pytestconfig.getoption(
            'heptapod_reverse_call_host'),
190
191
        webdriver_remote_url=pytestconfig.getoption(
            'heptapod_webdriver_remote_url'),
192
193
        wait_after_first_response=pytestconfig.getoption(
            'heptapod_wait_after_first_response'),
194
        hg_native=pytestconfig.getoption('heptapod_hg_native')
195
    )
196
197
198
199

    # make tests fully independent of current user settings
    os.environ['HGRCPATH'] = ''

200
201
202
203
204
205
206
    try:
        if active_threads == 1:  # we're the first
            if pytestconfig.getoption('heptapod_source_install'):
                repos_root = pytestconfig.getoption(
                    'heptapod_repositories_root')
                heptapod = SourceHeptapod(repositories_root=repos_root,
                                          **common)
207
208
209
210
            elif pytestconfig.getoption('heptapod_gdk'):
                heptapod = GdkHeptapod(
                    gdk_root=pytestconfig.getoption('heptapod_gdk_root'),
                    **common)
211
212
            elif pytestconfig.getoption('heptapod_remote'):
                heptapod = Heptapod(**common)
213
214
            elif pytestconfig.getoption('heptapod_omnibus'):
                heptapod = OmnibusHeptapod(**common)
215
216
217
218
219
220
221
222
223
224
            else:
                ct = pytestconfig.getoption('heptapod_docker_container')
                heptapod = DockerHeptapod(docker_container=ct, **common)

            heptapod.prepare(pytestconfig.getoption('heptapod_root_password'))
            heptapod_instance = heptapod
    finally:
        lock.release()

    yield heptapod_instance
225

226
227
    lock.acquire()
    active_threads -= 1
228
    try:
229
230
        if active_threads == 0:
            heptapod_instance.close()
231
    finally:
232
        lock.release()
233
234


235
@contextlib.contextmanager
Georges Racinet's avatar
Georges Racinet committed
236
def project_fixture(heptapod, name_prefix, owner, group=None, **opts):
237
    name = '%s_%s' % (name_prefix, str(time.time()).replace('.', '_'))
238
239
    with Project.api_create(heptapod, owner, name,
                            group=group, **opts) as proj:
240
        yield proj
Georges Racinet's avatar
Georges Racinet committed
241
242


Georges Racinet's avatar
Georges Racinet committed
243
244
@contextlib.contextmanager
def group_fixture(heptapod, path_prefix, creator_name='root', parent=None):
245
246
247
    with Group.api_create(heptapod, unique_name(path_prefix),
                          user_name=creator_name,
                          parent=parent) as group:
Georges Racinet's avatar
Georges Racinet committed
248
249
250
        yield group


Georges Racinet's avatar
Georges Racinet committed
251
252
@pytest.fixture()
def external_user(heptapod, accepts_concurrent):
253
254
    with User.create(heptapod, unique_name('external_user'),
                     external=True) as user:
Georges Racinet's avatar
Georges Racinet committed
255
256
257
        yield user


258
259
@pytest.fixture()
def additional_user(heptapod, accepts_concurrent):
260
    with User.create(heptapod, unique_name('add_user')) as user:
261
262
263
264
        user.ensure_private_token()
        yield user


Georges Racinet's avatar
Georges Racinet committed
265
@pytest.fixture()
266
def test_project(heptapod, accepts_concurrent):
267
268
    with project_fixture(heptapod, 'test_project', 'root') as proj:
        yield proj
Georges Racinet's avatar
Georges Racinet committed
269
270


271
272
273
274
275
276
@pytest.fixture()
def test_project_with_runner(test_project):
    with Runner.api_register(test_project, unique_name('fixt')) as runner:
        yield test_project, runner


Georges Racinet's avatar
Georges Racinet committed
277
278
279
280
281
282
@pytest.fixture()
def test_group(heptapod, accepts_concurrent):
    with group_fixture(heptapod, 'test_group', 'root') as group:
        yield group


Georges Racinet's avatar
Georges Racinet committed
283
@pytest.fixture()
284
285
286
287
288
289
290
291
def project_breaks_concurrent(heptapod, breaks_concurrent):
    """Used for tests that break concurrent and need a testing project"""
    with project_fixture(heptapod, 'test_project', 'root') as proj:
        yield proj


@pytest.fixture()
def public_project(heptapod, accepts_concurrent):
292
    with project_fixture(heptapod, 'public_project', 'test_basic') as proj:
293
294
295
296
297
        resp = proj.api_edit(visibility='public')
        assert resp.status_code == 200
        yield proj


Georges Racinet's avatar
Georges Racinet committed
298
299
300
301
302
303
304
@pytest.fixture()
def git_project(heptapod, accepts_concurrent):
    with project_fixture(heptapod, 'git_project', 'test_basic',
                         vcs_type='git') as proj:
        yield proj


305
306
307
308
309
310
@pytest.fixture()
def git_project_with_runner(git_project):
    with Runner.api_register(git_project, unique_name('fixt')) as runner:
        yield git_project, runner


311
312
313
@pytest.fixture()
def public_project_breaks_concurrent(heptapod, breaks_concurrent):
    with project_fixture(heptapod, 'public_project', 'test_basic') as proj:
Georges Racinet's avatar
Georges Racinet committed
314
315
316
        resp = proj.api_edit(visibility='public')
        assert resp.status_code == 200
        yield proj
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378


no_concurrency_lock = threading.Lock()
"""Lock to implement cases where concurrency is to be avoided.

Tests that break concurrency will keep a hold on this lock.
Other tests will release it right after startup.
"""

running_count_change = threading.Condition()
"""Guard changes in the number of running tests accepting concurrency.

Tests that break concurrency will wait for this condition.
Other tests will notify it.
"""

running_tests = 0
"""Number of tests accepting concurrency that are currently running."""


def thread_name():
    return threading.current_thread().name


@pytest.fixture()
def breaks_concurrent():
    """Fixture for tests that cannot run concurrently with any other one.

    For instance, some tests will restart the Rails application, hence breaking
    anything that's concurrent. Use this fixture to declare them as such, and
    have pytest do the right thing, hence avoid launching concurrent tests
    while they are running.
    """
    # make sure that we are in the only test breaking concurrency
    # that can proceed
    with no_concurrency_lock:
        # now that we have this lock, all concurrent tests should be based on
        # the `accepts_concurrent` fixture (barring forgotten declarations)
        logger.info("[%s] breaks_concurrency(): waiting "
                    "for concurrent tests to finish", thread_name())
        running_count_change.acquire()
        while running_tests > 0:
            logger.debug("[%s] breaks_concurrency(): "
                         "there are %d running concurrent tests",
                         thread_name(), running_tests)
            running_count_change.wait()
        running_count_change.release()
        logger.info("[%s] breaks_concurrency(): proceeding to the test",
                    thread_name())
        yield

    logger.debug("[%s] breaks_concurrency() done", thread_name())


@pytest.fixture()
def accepts_concurrent():
    """Fixture for tests that can run concurrently among each other.

    Of course they can't run concurrently with tests that use the
    :func:`breaks_concurrent` fixture
    """
    global running_tests
379
380
    logger.debug("[%s] accepts_concurrent(): waiting for "
                 "tests breaking concurrency to finish", thread_name())
381
382
383
384
385
386
387
    # wait for tests breaking concurrency to finish.
    with no_concurrency_lock:
        # while no other thread can hold the no_concurrency_lock,
        # some other test might be tearing down, using the lock
        # behind running_count_changes
        with running_count_change:
            running_tests += 1
388
389
390
            logger.debug("[%s] accepts_concurrent(): incremented counter to "
                         "%d and proceeding to the test",
                         thread_name(), running_tests)
391
392
393
394
    yield

    with running_count_change:
        running_tests -= 1
395
396
397
        logger.debug("[%s] accepts_concurrent(): decremented to %d "
                     "and notifying tests that break concurrency",
                     thread_name(), running_tests)
398
        running_count_change.notify()
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423


@pytest.fixture()
def app_settings(heptapod):
    """Fixture for tests that change application settings.

    This restores these settings in the teardown.

    The value yielded is the `Heptapod` instance.

    Depending on the existence of other tests that rely on a given value
    of the changed settings, these could break concurrency or not.
    """
    heptapod.sync_application_settings()
    settings = heptapod.settings
    yield heptapod

    new_settings = heptapod.settings
    # stored settings (from API GET or PUT responses) typically have
    # much more than what can be set, and include problematic values.
    # Let's rollback only what has changed
    rollback = {name: value for name, value in settings.items()
                if new_settings.get(name) != value}
    if rollback:
        heptapod.set_application_settings(**rollback)