Read about our upcoming Code of Conduct on this issue

conftest.py 19.2 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
    ProductionHeptapod,
19
    OmnibusHeptapod,
20
21
    DockerHeptapod,
    SourceHeptapod,
22
    GdkHeptapod,
23
)
24

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

28

29
30
def pytest_addoption(parser):
    """Add command-line options for Heptapod host."""
31
32
33
34
35
    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.")
36
37
38
39
40
41
42
43
44
    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."
                     )
45
46
47
    parser.addoption('--heptapod-omnibus', action="store_true",
                     help="Test a Heptapod Omnibus by running the tests "
                     "as root on the same system ")
48
49
50
51
52
    parser.addoption('--heptapod-prod-server', action='store_true',
                     help="Run only those tests that are suitable "
                     "for a production installation, with precreated "
                     "users instead of taking with the root account. "
                     "Implies --heptapod-remote and requires more parameters.")
53
54
55
56
57
58
59
60
61
62
    group = parser.getgroup(
        'Heptapod production servers',
        description="Required and optional settings to run functional "
        "tests against production servers")
    group.addoption('--heptapod-prod-group-owner-credentials',
                    help="prod servers only: numeric id, username and "
                    "password for owner of the group where most of the "
                    "testing will happen. "
                    "Provided as colon separated fields, such as "
                    "123:ftest_owner:s1cre3t.")
63
64
65
    group.addoption('--heptapod-prod-group-id', type=int,
                    help="prod servers only: numeric id for the group "
                    "where most projects and subgroups are to be created. "
66
67
68
                    "If not specified, a random group with minimal depth "
                    "among those owned by the user given in "
                    "--heptapod-prod-group-owner-credentials will be used."
69
                    "Some tests may still use personal namespaces.")
70
71
72
73
74
    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.")
75
76
77
    parser.addoption('--heptapod-hg-native', action='store_true',
                     help="Have all Mercurial projects created as 'native' "
                     "(HGitaly backed)")
78
    parser.addoption('--heptapod-repositories-root',
79
                     help="Root directory of the repository storage. "
80
81
                     "This is mandatory for source installs and ignored "
                     "in Docker mode.")
82
83
84
    parser.addoption('--heptapod-gdk-root',
                     help="Root of the installation for GDK Heptapod.")

85
86
    parser.addoption('--heptapod-url', default='http://heptapod:81')
    parser.addoption('--heptapod-ssh-port', type=int, default=2022)
87
    parser.addoption('--heptapod-ssh-user', default='git')
88
    parser.addoption('--heptapod-docker-container', default='heptapod')
89
    parser.addoption('--heptapod-root-password', default='5iveL!fe')
90
91
92
93
    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.")
94
95
96
97
98
99
100
    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")
101
102
103
104
105
106
107
108
    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")
109
110
111
112
113
    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.")
114
115
116
117
118
    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.")
119
120


121
122
123
124
def pytest_configure(config):
    config.addinivalue_line("markers",
                            "fs_access: mark test as needing file system "
                            "access to Heptapod server (Docker or local).")
125
126
127
128
    config.addinivalue_line("markers",
                            "reverse_call: mark test as involving network "
                            "calls from the Heptapod server to the host "
                            "running these tests")
129
130
131
    config.addinivalue_line("markers",
                            "docker: mark test as needing to run against "
                            "Docker Heptapod servers")
132
133
134
135
    config.addinivalue_line("markers",
                            "services: mark test as needing services "
                            "management "
                            )
136
137
138
139
140
141
142
143
144
145
    # 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"
                            )
146
147
148
149
    config.addinivalue_line("markers",
                            "prod_server: mark test as suitable "
                            "on production servers."
                            )
150
151


152
153
def pytest_collection_modifyitems(config, items):
    skip_fs_needed = pytest.mark.skip(reason="needs filesystem access")
154
155
    skip_docker = pytest.mark.skip(
        reason="needs Heptapod as a Docker container")
156
157
158
    skip_reverse_call = pytest.mark.skip(
        reason="needs to be able to initiate network connections to the host "
        "running these tests")
159
160
    skip_services = pytest.mark.skip(
        reason="needs to manage Heptapod services")
161
162
    skip_hg_git = pytest.mark.skip(reason="needs non hg-native mode")
    skip_hg_native = pytest.mark.skip(reason="needs hg-native mode")
163
164
    skip_prod_server = pytest.mark.skip(
        reason="not meant for production servers ")
165

166
    no_reverse_call = not(config.getoption('heptapod_reverse_call_host'))
167
168
    prod_server = config.getoption('heptapod_prod_server')
    remote = config.getoption('heptapod_remote') or prod_server
169
    source_install = config.getoption('heptapod_source_install')
170
    gdk = config.getoption('heptapod_gdk')
171
172
    omnibus = config.getoption('heptapod_omnibus')
    docker = not (gdk or source_install or remote or omnibus)
173
    hg_native = config.getoption("heptapod_hg_native")
174

175
    for item in items:
176
        if remote and "fs_access" in item.keywords:
177
            item.add_marker(skip_fs_needed)
178
        if "docker" in item.keywords and not docker:
179
            item.add_marker(skip_docker)
180
181
        if "reverse_call" in item.keywords and no_reverse_call:
            item.add_marker(skip_reverse_call)
182
183
        if "services" in item.keywords and (remote or source_install):
            item.add_marker(skip_services)
184
185
186
187
        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)
188
189
        if prod_server and "prod_server" not in item.keywords:
            item.add_marker(skip_prod_server)
190
191


192
193
194
195
196
heptapod_instance = None
lock = threading.Lock()
active_threads = 0


Georges Racinet's avatar
Georges Racinet committed
197
@pytest.fixture(scope="session")
198
def heptapod(pytestconfig):
199
200
201
202
203
204
205
    global lock
    global active_threads
    global heptapod_instance

    lock.acquire()
    active_threads += 1

206
207
208
209
210
    # 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.
211
    hg.HG_EXECUTABLE = pytestconfig.getoption('heptapod_hg_client_executable')
212
213
    git.GIT_EXECUTABLE = pytestconfig.getoption(
        'heptapod_git_client_executable')
214

215
    url = pytestconfig.getoption('heptapod_url')
216
    common = dict(
217
        url=url,
218
        ssh_user=pytestconfig.getoption('heptapod_ssh_user'),
219
        ssh_port=pytestconfig.getoption('heptapod_ssh_port'),
220
221
        reverse_call_host=pytestconfig.getoption(
            'heptapod_reverse_call_host'),
222
223
        webdriver_remote_url=pytestconfig.getoption(
            'heptapod_webdriver_remote_url'),
224
225
        wait_after_first_response=pytestconfig.getoption(
            'heptapod_wait_after_first_response'),
226
        hg_native=pytestconfig.getoption('heptapod_hg_native')
227
    )
228
229
230
231

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

232
233
    try:
        if active_threads == 1:  # we're the first
234
            if pytestconfig.getoption('heptapod_prod_server'):
235
236
237
238
239
240
241
242
                owner_creds = pytestconfig.getoption(
                    'heptapod_prod_group_owner_credentials')
                if owner_creds is None:
                    raise RuntimeError(
                        "Production server mode: please specify a Group owner "
                        "with the --heptapod-prod-group-owner-credentials "
                        "option (see --help for syntax)")

243
                heptapod = ProductionHeptapod(
244
                    group_owner_credentials=owner_creds,
245
246
                    default_group_id=pytestconfig.getoption(
                        'heptapod_prod_group_id'),
247
248
249
                    **common
                )
            elif pytestconfig.getoption('heptapod_source_install'):
250
251
252
253
                repos_root = pytestconfig.getoption(
                    'heptapod_repositories_root')
                heptapod = SourceHeptapod(repositories_root=repos_root,
                                          **common)
254
255
256
257
            elif pytestconfig.getoption('heptapod_gdk'):
                heptapod = GdkHeptapod(
                    gdk_root=pytestconfig.getoption('heptapod_gdk_root'),
                    **common)
258
259
            elif pytestconfig.getoption('heptapod_remote'):
                heptapod = Heptapod(**common)
260
261
            elif pytestconfig.getoption('heptapod_omnibus'):
                heptapod = OmnibusHeptapod(**common)
262
263
264
265
266
267
268
269
270
271
            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
272

273
274
    lock.acquire()
    active_threads -= 1
275
    try:
276
277
        if active_threads == 0:
            heptapod_instance.close()
278
    finally:
279
        lock.release()
280
281


282
@contextlib.contextmanager
283
284
285
def project_fixture(heptapod, name_prefix, owner=None, group=None, **opts):
    if owner is None:
        owner = heptapod.default_user_name
286
287
    if group is None:
        group = heptapod.default_group
288
    name = '%s_%s' % (name_prefix, str(time.time()).replace('.', '_'))
289
290
    with Project.api_create(heptapod, owner, name,
                            group=group, **opts) as proj:
291
        yield proj
Georges Racinet's avatar
Georges Racinet committed
292
293


Georges Racinet's avatar
Georges Racinet committed
294
295
@contextlib.contextmanager
def group_fixture(heptapod, path_prefix, creator_name='root', parent=None):
296
297
298
    with Group.api_create(heptapod, unique_name(path_prefix),
                          user_name=creator_name,
                          parent=parent) as group:
Georges Racinet's avatar
Georges Racinet committed
299
300
301
        yield group


Georges Racinet's avatar
Georges Racinet committed
302
303
@pytest.fixture()
def external_user(heptapod, accepts_concurrent):
304
305
    with User.create(heptapod, unique_name('external_user'),
                     external=True) as user:
Georges Racinet's avatar
Georges Racinet committed
306
307
308
        yield user


309
310
@pytest.fixture()
def additional_user(heptapod, accepts_concurrent):
311
    with User.create(heptapod, unique_name('add_user')) as user:
312
313
314
315
        user.ensure_private_token()
        yield user


Georges Racinet's avatar
Georges Racinet committed
316
@pytest.fixture()
317
def test_project(heptapod, accepts_concurrent):
318
    with project_fixture(heptapod, 'test_project') as proj:
319
        yield proj
Georges Racinet's avatar
Georges Racinet committed
320
321


322
323
324
325
326
327
@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
328
329
330
331
332
333
@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
334
@pytest.fixture()
335
336
337
338
339
340
341
342
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):
343
    with project_fixture(heptapod, 'public_project', 'test_basic') as proj:
344
345
346
347
348
        resp = proj.api_edit(visibility='public')
        assert resp.status_code == 200
        yield proj


Georges Racinet's avatar
Georges Racinet committed
349
350
351
352
353
354
355
@pytest.fixture()
def git_project(heptapod, accepts_concurrent):
    with project_fixture(heptapod, 'git_project', 'test_basic',
                         vcs_type='git') as proj:
        yield proj


356
357
358
359
360
361
@pytest.fixture()
def git_project_with_runner(git_project):
    with Runner.api_register(git_project, unique_name('fixt')) as runner:
        yield git_project, runner


362
363
364
@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
365
366
367
        resp = proj.api_edit(visibility='public')
        assert resp.status_code == 200
        yield proj
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
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
424
425
426
427
428
429


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
430
431
    logger.debug("[%s] accepts_concurrent(): waiting for "
                 "tests breaking concurrency to finish", thread_name())
432
433
434
435
436
437
438
    # 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
439
440
441
            logger.debug("[%s] accepts_concurrent(): incremented counter to "
                         "%d and proceeding to the test",
                         thread_name(), running_tests)
442
443
444
445
    yield

    with running_count_change:
        running_tests -= 1
446
447
448
        logger.debug("[%s] accepts_concurrent(): decremented to %d "
                     "and notifying tests that break concurrency",
                     thread_name(), running_tests)
449
        running_count_change.notify()
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474


@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)