source: trunk/integration/conftest.py

Last change on this file was 8b17538, checked in by meejah <meejah@…>, at 2023-08-02T21:15:33Z

flake8

  • Property mode set to 100644
File size: 16.1 KB
Line 
1"""
2Ported to Python 3.
3"""
4
5from __future__ import annotations
6
7import os
8import sys
9import shutil
10from attr import frozen
11from time import sleep
12from os import mkdir, environ
13from os.path import join, exists
14from tempfile import mkdtemp
15
16from eliot import (
17    to_file,
18    log_call,
19)
20
21from twisted.python.filepath import FilePath
22from twisted.python.procutils import which
23from twisted.internet.defer import DeferredList, succeed
24from twisted.internet.error import (
25    ProcessExitedAlready,
26    ProcessTerminated,
27)
28
29import pytest
30import pytest_twisted
31from typing import Mapping
32
33from .util import (
34    _MagicTextProtocol,
35    _DumpOutputProtocol,
36    _ProcessExitedProtocol,
37    _create_node,
38    _tahoe_runner_optional_coverage,
39    await_client_ready,
40    block_with_timeout,
41)
42from .grid import (
43    create_flog_gatherer,
44    create_grid,
45)
46from allmydata.node import read_config
47from allmydata.util.iputil import allocate_tcp_port
48
49# No reason for HTTP requests to take longer than four minutes in the
50# integration tests. See allmydata/scripts/common_http.py for usage.
51os.environ["__TAHOE_CLI_HTTP_TIMEOUT"] = "240"
52
53# Make Foolscap logging go into Twisted logging, so that integration test logs
54# include extra information
55# (https://github.com/warner/foolscap/blob/latest-release/doc/logging.rst):
56os.environ["FLOGTOTWISTED"] = "1"
57
58# pytest customization hooks
59
60def pytest_addoption(parser):
61    parser.addoption(
62        "--keep-tempdir", action="store_true", dest="keep",
63        help="Keep the tmpdir with the client directories (introducer, etc)",
64    )
65    parser.addoption(
66        "--coverage", action="store_true", dest="coverage",
67        help="Collect coverage statistics",
68    )
69    parser.addoption(
70        "--force-foolscap", action="store_true", default=False,
71        dest="force_foolscap",
72        help=("If set, force Foolscap only for the storage protocol. " +
73              "Otherwise HTTP will be used.")
74    )
75    parser.addoption(
76        "--runslow", action="store_true", default=False,
77        dest="runslow",
78        help="If set, run tests marked as slow.",
79    )
80
81def pytest_collection_modifyitems(session, config, items):
82    if not config.option.runslow:
83        # The --runslow option was not given; keep only collected items not
84        # marked as slow.
85        items[:] = [
86            item
87            for item
88            in items
89            if item.get_closest_marker("slow") is None
90        ]
91
92
93@pytest.fixture(autouse=True, scope='session')
94def eliot_logging():
95    with open("integration.eliot.json", "w") as f:
96        to_file(f)
97        yield
98
99
100# I've mostly defined these fixtures from "easiest" to "most
101# complicated", and the dependencies basically go "down the
102# page". They're all session-scoped which has the "pro" that we only
103# set up the grid once, but the "con" that each test has to be a
104# little careful they're not stepping on toes etc :/
105
106@pytest.fixture(scope='session')
107@log_call(action_type=u"integration:reactor", include_result=False)
108def reactor():
109    # this is a fixture in case we might want to try different
110    # reactors for some reason.
111    from twisted.internet import reactor as _reactor
112    return _reactor
113
114
115@pytest.fixture(scope='session')
116@log_call(action_type=u"integration:port_allocator", include_result=False)
117def port_allocator(reactor):
118    # these will appear basically random, which can make especially
119    # manual debugging harder but we're re-using code instead of
120    # writing our own...so, win?
121    def allocate():
122        port = allocate_tcp_port()
123        return succeed(port)
124    return allocate
125
126
127@pytest.fixture(scope='session')
128@log_call(action_type=u"integration:temp_dir", include_args=[])
129def temp_dir(request) -> str:
130    """
131    Invoke like 'py.test --keep-tempdir ...' to avoid deleting the temp-dir
132    """
133    tmp = mkdtemp(prefix="tahoe")
134    if request.config.getoption('keep'):
135        print("\nWill retain tempdir '{}'".format(tmp))
136
137    # I'm leaving this in and always calling it so that the tempdir
138    # path is (also) printed out near the end of the run
139    def cleanup():
140        if request.config.getoption('keep'):
141            print("Keeping tempdir '{}'".format(tmp))
142        else:
143            try:
144                shutil.rmtree(tmp, ignore_errors=True)
145            except Exception as e:
146                print("Failed to remove tmpdir: {}".format(e))
147    request.addfinalizer(cleanup)
148
149    return tmp
150
151
152@pytest.fixture(scope='session')
153@log_call(action_type=u"integration:flog_binary", include_args=[])
154def flog_binary():
155    return which('flogtool')[0]
156
157
158@pytest.fixture(scope='session')
159@log_call(action_type=u"integration:flog_gatherer", include_args=[])
160def flog_gatherer(reactor, temp_dir, flog_binary, request):
161    fg = pytest_twisted.blockon(
162        create_flog_gatherer(reactor, request, temp_dir, flog_binary)
163    )
164    return fg
165
166
167@pytest.fixture(scope='session')
168@log_call(action_type=u"integration:grid", include_args=[])
169def grid(reactor, request, temp_dir, flog_gatherer, port_allocator):
170    """
171    Provides a new Grid with a single Introducer and flog-gathering process.
172
173    Notably does _not_ provide storage servers; use the storage_nodes
174    fixture if your tests need a Grid that can be used for puts / gets.
175    """
176    g = pytest_twisted.blockon(
177        create_grid(reactor, request, temp_dir, flog_gatherer, port_allocator)
178    )
179    return g
180
181
182@pytest.fixture(scope='session')
183def introducer(grid):
184    return grid.introducer
185
186
187@pytest.fixture(scope='session')
188@log_call(action_type=u"integration:introducer:furl", include_args=["temp_dir"])
189def introducer_furl(introducer, temp_dir):
190    return introducer.furl
191
192
193@pytest.fixture
194@log_call(
195    action_type=u"integration:tor:introducer",
196    include_args=["temp_dir", "flog_gatherer"],
197    include_result=False,
198)
199def tor_introducer(reactor, temp_dir, flog_gatherer, request, tor_network):
200    intro_dir = join(temp_dir, 'introducer_tor')
201    print("making Tor introducer in {}".format(intro_dir))
202    print("(this can take tens of seconds to allocate Onion address)")
203
204    if not exists(intro_dir):
205        mkdir(intro_dir)
206        done_proto = _ProcessExitedProtocol()
207        _tahoe_runner_optional_coverage(
208            done_proto,
209            reactor,
210            request,
211            (
212                'create-introducer',
213                '--tor-control-port', tor_network.client_control_endpoint,
214                '--hide-ip',
215                '--listen=tor',
216                intro_dir,
217            ),
218        )
219        pytest_twisted.blockon(done_proto.done)
220
221    # adjust a few settings
222    config = read_config(intro_dir, "tub.port")
223    config.set_config("node", "nickname", "introducer-tor")
224    config.set_config("node", "web.port", "4561")
225    config.set_config("node", "log_gatherer.furl", flog_gatherer.furl)
226
227    # "tahoe run" is consistent across Linux/macOS/Windows, unlike the old
228    # "start" command.
229    protocol = _MagicTextProtocol('introducer running', "tor_introducer")
230    transport = _tahoe_runner_optional_coverage(
231        protocol,
232        reactor,
233        request,
234        (
235            'run',
236            intro_dir,
237        ),
238    )
239
240    def cleanup():
241        try:
242            transport.signalProcess('TERM')
243            block_with_timeout(protocol.exited, reactor)
244        except ProcessExitedAlready:
245            pass
246    request.addfinalizer(cleanup)
247
248    print("Waiting for introducer to be ready...")
249    pytest_twisted.blockon(protocol.magic_seen)
250    print("Introducer ready.")
251    return transport
252
253
254@pytest.fixture
255def tor_introducer_furl(tor_introducer, temp_dir):
256    furl_fname = join(temp_dir, 'introducer_tor', 'private', 'introducer.furl')
257    while not exists(furl_fname):
258        print("Don't see {} yet".format(furl_fname))
259        sleep(.1)
260    furl = open(furl_fname, 'r').read()
261    print(f"Found Tor introducer furl: {furl} in {furl_fname}")
262    return furl
263
264
265@pytest.fixture(scope='session')
266@log_call(
267    action_type=u"integration:storage_nodes",
268    include_args=["grid"],
269    include_result=False,
270)
271def storage_nodes(grid):
272    nodes_d = []
273    # start all 5 nodes in parallel
274    for x in range(5):
275        nodes_d.append(grid.add_storage_node())
276
277    nodes_status = pytest_twisted.blockon(DeferredList(nodes_d))
278    for ok, value in nodes_status:
279        assert ok, "Storage node creation failed: {}".format(value)
280    return grid.storage_servers
281
282
283@pytest.fixture(scope='session')
284@log_call(action_type=u"integration:alice", include_args=[], include_result=False)
285def alice(reactor, request, grid, storage_nodes):
286    """
287    :returns grid.Client: the associated instance for Alice
288    """
289    alice = pytest_twisted.blockon(grid.add_client("alice"))
290    pytest_twisted.blockon(alice.add_sftp(reactor, request))
291    print(f"Alice pid: {alice.process.transport.pid}")
292    return alice
293
294
295@pytest.fixture(scope='session')
296@log_call(action_type=u"integration:bob", include_args=[], include_result=False)
297def bob(reactor, temp_dir, introducer_furl, flog_gatherer, storage_nodes, request):
298    process = pytest_twisted.blockon(
299        _create_node(
300            reactor, request, temp_dir, introducer_furl, flog_gatherer, "bob",
301            web_port="tcp:9981:interface=localhost",
302            storage=False,
303        )
304    )
305    pytest_twisted.blockon(await_client_ready(process))
306    return process
307
308
309@pytest.fixture(scope='session')
310@pytest.mark.skipif(sys.platform.startswith('win'),
311                    'Tor tests are unstable on Windows')
312def chutney(reactor, temp_dir: str) -> tuple[str, dict[str, str]]:
313    """
314    Install the Chutney software that is required to run a small local Tor grid.
315
316    (Chutney lacks the normal "python stuff" so we can't just declare
317    it in Tox or similar dependencies)
318    """
319    # Try to find Chutney already installed in the environment.
320    try:
321        import chutney
322    except ImportError:
323        # Nope, we'll get our own in a moment.
324        pass
325    else:
326        # We already have one, just use it.
327        return (
328            # from `checkout/lib/chutney/__init__.py` we want to get back to
329            # `checkout` because that's the parent of the directory with all
330            # of the network definitions.  So, great-grand-parent.
331            FilePath(chutney.__file__).parent().parent().parent().path,
332            # There's nothing to add to the environment.
333            {},
334        )
335
336    chutney_dir = join(temp_dir, 'chutney')
337    mkdir(chutney_dir)
338
339    missing = [exe for exe in ["tor", "tor-gencert"] if not which(exe)]
340    if missing:
341        pytest.skip(f"Some command-line tools not found: {missing}")
342
343    # XXX yuck! should add a setup.py to chutney so we can at least
344    # "pip install <path to tarball>" and/or depend on chutney in "pip
345    # install -e .[dev]" (i.e. in the 'dev' extra)
346    #
347    # https://trac.torproject.org/projects/tor/ticket/20343
348    proto = _DumpOutputProtocol(None)
349    reactor.spawnProcess(
350        proto,
351        'git',
352        (
353            'git', 'clone',
354            'https://gitlab.torproject.org/tpo/core/chutney.git',
355            chutney_dir,
356        ),
357        env=environ,
358    )
359    pytest_twisted.blockon(proto.done)
360
361    # XXX: Here we reset Chutney to a specific revision known to work,
362    # since there are no stability guarantees or releases yet.
363    proto = _DumpOutputProtocol(None)
364    reactor.spawnProcess(
365        proto,
366        'git',
367        (
368            'git', '-C', chutney_dir,
369            'reset', '--hard',
370            'c4f6789ad2558dcbfeb7d024c6481d8112bfb6c2'
371        ),
372        env=environ,
373    )
374    pytest_twisted.blockon(proto.done)
375
376    return chutney_dir, {"PYTHONPATH": join(chutney_dir, "lib")}
377
378
379@frozen
380class ChutneyTorNetwork:
381    """
382    Represents a running Chutney (tor) network. Returned by the
383    "tor_network" fixture.
384    """
385    dir: FilePath
386    environ: Mapping[str, str]
387    client_control_port: int
388
389    @property
390    def client_control_endpoint(self) -> str:
391        return "tcp:localhost:{}".format(self.client_control_port)
392
393
394@pytest.fixture(scope='session')
395@pytest.mark.skipif(sys.platform.startswith('win'),
396                    reason='Tor tests are unstable on Windows')
397def tor_network(reactor, temp_dir, chutney, request):
398    """
399    Build a basic Tor network.
400
401    Instantiate the "networks/basic" Chutney configuration for a local
402    Tor network.
403
404    This provides a small, local Tor network that can run v3 Onion
405    Services. It has 3 authorities, 5 relays and 2 clients.
406
407    The 'chutney' fixture pins a Chutney git qrevision, so things
408    shouldn't change. This network has two clients which are the only
409    nodes with valid SocksPort configuration ("008c" and "009c" 9008
410    and 9009)
411
412    The control ports start at 8000 (so the ControlPort for the client
413    nodes are 8008 and 8009).
414
415    :param chutney: The root directory of a Chutney checkout and a dict of
416        additional environment variables to set so a Python process can use
417        it.
418
419    :return: None
420    """
421    chutney_root, chutney_env = chutney
422    basic_network = join(chutney_root, 'networks', 'basic')
423
424    env = environ.copy()
425    env.update(chutney_env)
426    env.update({
427        # default is 60, probably too short for reliable automated use.
428        "CHUTNEY_START_TIME": "600",
429    })
430    chutney_argv = (sys.executable, '-m', 'chutney.TorNet')
431    def chutney(argv):
432        proto = _DumpOutputProtocol(None)
433        reactor.spawnProcess(
434            proto,
435            sys.executable,
436            chutney_argv + argv,
437            path=join(chutney_root),
438            env=env,
439        )
440        return proto.done
441
442    # now, as per Chutney's README, we have to create the network
443    pytest_twisted.blockon(chutney(("configure", basic_network)))
444
445    # before we start the network, ensure we will tear down at the end
446    def cleanup():
447        print("Tearing down Chutney Tor network")
448        try:
449            block_with_timeout(chutney(("stop", basic_network)), reactor)
450        except ProcessTerminated:
451            # If this doesn't exit cleanly, that's fine, that shouldn't fail
452            # the test suite.
453            pass
454    request.addfinalizer(cleanup)
455
456    pytest_twisted.blockon(chutney(("start", basic_network)))
457
458    # Wait for the nodes to "bootstrap" - ie, form a network among themselves.
459    # Successful bootstrap is reported with a message something like:
460    #
461    # Everything bootstrapped after 151 sec
462    # Bootstrap finished: 151 seconds
463    # Node status:
464    # test000a     :  100, done                     , Done
465    # test001a     :  100, done                     , Done
466    # test002a     :  100, done                     , Done
467    # test003r     :  100, done                     , Done
468    # test004r     :  100, done                     , Done
469    # test005r     :  100, done                     , Done
470    # test006r     :  100, done                     , Done
471    # test007r     :  100, done                     , Done
472    # test008c     :  100, done                     , Done
473    # test009c     :  100, done                     , Done
474    # Published dir info:
475    # test000a     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
476    # test001a     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
477    # test002a     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
478    # test003r     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
479    # test004r     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
480    # test005r     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
481    # test006r     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
482    # test007r     :  100, all nodes                , desc md md_cons ns_cons       , Dir info cached
483    pytest_twisted.blockon(chutney(("wait_for_bootstrap", basic_network)))
484
485    # print some useful stuff
486    try:
487        pytest_twisted.blockon(chutney(("status", basic_network)))
488    except ProcessTerminated:
489        print("Chutney.TorNet status failed (continuing)")
490
491    # the "8008" comes from configuring "networks/basic" in chutney
492    # and then examining "net/nodes/008c/torrc" for ControlPort value
493    return ChutneyTorNetwork(
494        chutney_root,
495        chutney_env,
496        8008,
497    )
Note: See TracBrowser for help on using the repository browser.