source: trunk/src/allmydata/test/test_runner.py

Last change on this file was 53084f7, checked in by Alexandre Detiste <alexandre.detiste@…>, at 2024-02-27T23:49:07Z

remove more Python2 compatibility

  • Property mode set to 100644
File size: 22.7 KB
Line 
1"""
2Ported to Python 3
3"""
4
5import os.path, re, sys
6from os import linesep
7import locale
8
9from testtools.matchers import (
10    MatchesListwise,
11    MatchesAny,
12    Contains,
13    Equals,
14    Always,
15)
16from testtools.twistedsupport import (
17    succeeded,
18)
19from eliot import (
20    log_call,
21)
22
23from twisted.trial import unittest
24
25from twisted.internet import reactor
26from twisted.python import usage
27from twisted.python.runtime import platform
28from twisted.internet.defer import (
29    inlineCallbacks,
30    DeferredList,
31)
32from twisted.internet.testing import (
33    MemoryReactorClock,
34)
35from twisted.python.filepath import FilePath
36from allmydata.util import fileutil, pollmixin
37from allmydata.util.encodingutil import unicode_to_argv
38from allmydata.util.pid import (
39    check_pid_process,
40    _pidfile_to_lockpath,
41    ProcessInTheWay,
42)
43from allmydata.test import common_util
44import allmydata
45from allmydata.scripts.tahoe_run import (
46    on_stdin_close,
47)
48
49from .common import (
50    PIPE,
51    Popen,
52)
53from .common_util import (
54    parse_cli,
55    run_cli,
56    run_cli_unicode,
57)
58from .cli_node_api import (
59    CLINodeAPI,
60    Expect,
61    on_stdout,
62    on_stdout_and_stderr,
63)
64from ..util.eliotutil import (
65    inline_callbacks,
66)
67from .common import (
68    SyncTestCase,
69)
70
71def get_root_from_file(src):
72    srcdir = os.path.dirname(os.path.dirname(os.path.normcase(os.path.realpath(src))))
73
74    root = os.path.dirname(srcdir)
75    if os.path.basename(srcdir) == 'site-packages':
76        if re.search(r'python.+\..+', os.path.basename(root)):
77            root = os.path.dirname(root)
78        root = os.path.dirname(root)
79    elif os.path.basename(root) == 'src':
80        root = os.path.dirname(root)
81
82    return root
83
84srcfile = allmydata.__file__
85rootdir = get_root_from_file(srcfile)
86
87
88class ParseOrExitTests(SyncTestCase):
89    """
90    Tests for ``parse_or_exit``.
91    """
92    def test_nonascii_error_content(self):
93        """
94        ``parse_or_exit`` can report errors that include non-ascii content.
95        """
96        tricky = u"\u00F6"
97        self.assertThat(
98            run_cli_unicode(tricky, [], encoding="utf-8"),
99            succeeded(
100                MatchesListwise([
101                    # returncode
102                    Equals(1),
103                    # stdout
104                    MatchesAny(
105                        # Python 2
106                        Contains(u"Unknown command: \\xf6"),
107                        # Python 3
108                        Contains(u"Unknown command: \xf6"),
109                    ),
110                    # stderr,
111                    Always()
112                ]),
113            ),
114        )
115
116
117@log_call(action_type="run-bin-tahoe")
118def run_bintahoe(extra_argv, python_options=None):
119    """
120    Run the main Tahoe entrypoint in a child process with the given additional
121    arguments.
122
123    :param [unicode] extra_argv: More arguments for the child process argv.
124
125    :return: A three-tuple of stdout (unicode), stderr (unicode), and the
126        child process "returncode" (int).
127    """
128    argv = [sys.executable]
129    if python_options is not None:
130        argv.extend(python_options)
131    argv.extend([u"-b", u"-m", u"allmydata.scripts.runner"])
132    argv.extend(extra_argv)
133    argv = list(unicode_to_argv(arg) for arg in argv)
134    p = Popen(argv, stdout=PIPE, stderr=PIPE)
135    encoding = locale.getpreferredencoding(False)
136    out = p.stdout.read().decode(encoding)
137    err = p.stderr.read().decode(encoding)
138    returncode = p.wait()
139    return (out, err, returncode)
140
141
142class BinTahoe(common_util.SignalMixin, unittest.TestCase):
143    def test_unicode_arguments_and_output(self):
144        """
145        The runner script receives unmangled non-ASCII values in argv.
146        """
147        tricky = u"\u00F6"
148        out, err, returncode = run_bintahoe([tricky])
149        expected = u"Unknown command: \xf6"
150        self.assertEqual(returncode, 1)
151        self.assertIn(
152            expected,
153            out,
154            "expected {!r} not found in {!r}\nstderr: {!r}".format(expected, out, err),
155        )
156
157    def test_with_python_options(self):
158        """
159        Additional options for the Python interpreter don't prevent the runner
160        script from receiving the arguments meant for it.
161        """
162        # This seems like a redundant test for someone else's functionality
163        # but on Windows we parse the whole command line string ourselves so
164        # we have to have our own implementation of skipping these options.
165
166        # -B is a harmless option that prevents writing bytecode so we can add it
167        # without impacting other behavior noticably.
168        out, err, returncode = run_bintahoe([u"--version"], python_options=[u"-B"])
169        self.assertEqual(returncode, 0, f"Out:\n{out}\nErr:\n{err}")
170        self.assertTrue(out.startswith(allmydata.__appname__ + '/'))
171
172    def test_help_eliot_destinations(self):
173        out, err, returncode = run_bintahoe([u"--help-eliot-destinations"])
174        self.assertIn(u"\tfile:<path>", out)
175        self.assertEqual(returncode, 0)
176
177    def test_eliot_destination(self):
178        out, err, returncode = run_bintahoe([
179            # Proves little but maybe more than nothing.
180            u"--eliot-destination=file:-",
181            # Throw in *some* command or the process exits with error, making
182            # it difficult for us to see if the previous arg was accepted or
183            # not.
184            u"--help",
185        ])
186        self.assertEqual(returncode, 0)
187
188    def test_unknown_eliot_destination(self):
189        out, err, returncode = run_bintahoe([
190            u"--eliot-destination=invalid:more",
191        ])
192        self.assertEqual(1, returncode)
193        self.assertIn(u"Unknown destination description", out)
194        self.assertIn(u"invalid:more", out)
195
196    def test_malformed_eliot_destination(self):
197        out, err, returncode = run_bintahoe([
198            u"--eliot-destination=invalid",
199        ])
200        self.assertEqual(1, returncode)
201        self.assertIn(u"must be formatted like", out)
202
203    def test_escape_in_eliot_destination(self):
204        out, err, returncode = run_bintahoe([
205            u"--eliot-destination=file:@foo",
206        ])
207        self.assertEqual(1, returncode)
208        self.assertIn(u"Unsupported escape character", out)
209
210
211class CreateNode(unittest.TestCase):
212    # exercise "tahoe create-node" and "tahoe create-introducer" by calling
213    # the corresponding code as a subroutine.
214
215    def workdir(self, name):
216        basedir = os.path.join("test_runner", "CreateNode", name)
217        fileutil.make_dirs(basedir)
218        return basedir
219
220    @inlineCallbacks
221    def do_create(self, kind, *args):
222        basedir = self.workdir("test_" + kind)
223        command = "create-" + kind
224        is_client = kind in ("node", "client")
225        tac = is_client and "tahoe-client.tac" or ("tahoe-" + kind + ".tac")
226
227        n1 = os.path.join(basedir, command + "-n1")
228        argv = ["--quiet", command, "--basedir", n1] + list(args)
229        rc, out, err = yield run_cli(*map(unicode_to_argv, argv))
230        self.failUnlessEqual(err, "")
231        self.failUnlessEqual(out, "")
232        self.failUnlessEqual(rc, 0)
233        self.failUnless(os.path.exists(n1))
234        self.failUnless(os.path.exists(os.path.join(n1, tac)))
235
236        if is_client:
237            # tahoe.cfg should exist, and should have storage enabled for
238            # 'create-node', and disabled for 'create-client'.
239            tahoe_cfg = os.path.join(n1, "tahoe.cfg")
240            self.failUnless(os.path.exists(tahoe_cfg))
241            content = fileutil.read(tahoe_cfg).decode('utf-8').replace('\r\n', '\n')
242            if kind == "client":
243                self.failUnless(re.search(r"\n\[storage\]\n#.*\nenabled = false\n", content), content)
244            else:
245                self.failUnless(re.search(r"\n\[storage\]\n#.*\nenabled = true\n", content), content)
246                self.failUnless("\nreserved_space = 1G\n" in content)
247
248        # creating the node a second time should be rejected
249        rc, out, err = yield run_cli(*map(unicode_to_argv, argv))
250        self.failIfEqual(rc, 0, str((out, err, rc)))
251        self.failUnlessEqual(out, "")
252        self.failUnless("is not empty." in err)
253
254        # Fail if there is a non-empty line that doesn't end with a
255        # punctuation mark.
256        for line in err.splitlines():
257            self.failIf(re.search("[\S][^\.!?]$", line), (line,))
258
259        # test that the non --basedir form works too
260        n2 = os.path.join(basedir, command + "-n2")
261        argv = ["--quiet", command] + list(args) + [n2]
262        rc, out, err = yield run_cli(*map(unicode_to_argv, argv))
263        self.failUnlessEqual(err, "")
264        self.failUnlessEqual(out, "")
265        self.failUnlessEqual(rc, 0)
266        self.failUnless(os.path.exists(n2))
267        self.failUnless(os.path.exists(os.path.join(n2, tac)))
268
269        # test the --node-directory form
270        n3 = os.path.join(basedir, command + "-n3")
271        argv = ["--quiet", "--node-directory", n3, command] + list(args)
272        rc, out, err = yield run_cli(*map(unicode_to_argv, argv))
273        self.failUnlessEqual(err, "")
274        self.failUnlessEqual(out, "")
275        self.failUnlessEqual(rc, 0)
276        self.failUnless(os.path.exists(n3))
277        self.failUnless(os.path.exists(os.path.join(n3, tac)))
278
279        if kind in ("client", "node", "introducer"):
280            # test that the output (without --quiet) includes the base directory
281            n4 = os.path.join(basedir, command + "-n4")
282            argv = [command] + list(args) + [n4]
283            rc, out, err = yield run_cli(*map(unicode_to_argv, argv))
284            self.failUnlessEqual(err, "")
285            self.failUnlessIn(" created in ", out)
286            self.failUnlessIn(n4, out)
287            self.failIfIn("\\\\?\\", out)
288            self.failUnlessEqual(rc, 0)
289            self.failUnless(os.path.exists(n4))
290            self.failUnless(os.path.exists(os.path.join(n4, tac)))
291
292        # make sure it rejects too many arguments
293        self.failUnlessRaises(usage.UsageError, parse_cli,
294                              command, "basedir", "extraarg")
295
296        # when creating a non-client, there is no default for the basedir
297        if not is_client:
298            argv = [command]
299            self.failUnlessRaises(usage.UsageError, parse_cli,
300                                  command)
301
302    def test_node(self):
303        self.do_create("node", "--hostname=127.0.0.1")
304
305    def test_client(self):
306        # create-client should behave like create-node --no-storage.
307        self.do_create("client")
308
309    def test_introducer(self):
310        self.do_create("introducer", "--hostname=127.0.0.1")
311
312    def test_subcommands(self):
313        # no arguments should trigger a command listing, via UsageError
314        self.failUnlessRaises(usage.UsageError, parse_cli,
315                              )
316
317
318class RunNode(common_util.SignalMixin, unittest.TestCase, pollmixin.PollMixin):
319    """
320    exercise "tahoe run" for both introducer and client node, by spawning
321    "tahoe run" as a subprocess. This doesn't get us line-level coverage, but
322    it does a better job of confirming that the user can actually run
323    "./bin/tahoe run" and expect it to work. This verifies that bin/tahoe sets
324    up PYTHONPATH and the like correctly.
325    """
326
327    def workdir(self, name):
328        basedir = os.path.join("test_runner", "RunNode", name)
329        fileutil.make_dirs(basedir)
330        return basedir
331
332    @inline_callbacks
333    def test_introducer(self):
334        """
335        The introducer furl is stable across restarts.
336        """
337        basedir = self.workdir("test_introducer")
338        c1 = os.path.join(basedir, u"c1")
339        tahoe = CLINodeAPI(reactor, FilePath(c1))
340        self.addCleanup(tahoe.stop_and_wait)
341
342        out, err, returncode = run_bintahoe([
343            u"--quiet",
344            u"create-introducer",
345            u"--basedir", c1,
346            u"--hostname", u"127.0.0.1",
347        ])
348
349        self.assertEqual(
350            returncode,
351            0,
352            "stdout: {!r}\n"
353            "stderr: {!r}\n",
354        )
355
356        # This makes sure that node.url is written, which allows us to
357        # detect when the introducer restarts in _node_has_restarted below.
358        config = fileutil.read(tahoe.config_file.path).decode('utf-8')
359        self.assertIn('{}web.port = {}'.format(linesep, linesep), config)
360        fileutil.write(
361            tahoe.config_file.path,
362            config.replace(
363                '{}web.port = {}'.format(linesep, linesep),
364                '{}web.port = 0{}'.format(linesep, linesep),
365            )
366        )
367
368        p = Expect()
369        tahoe.run(on_stdout(p))
370        yield p.expect(b"introducer running")
371        tahoe.active()
372
373        yield self.poll(tahoe.introducer_furl_file.exists)
374
375        # read the introducer.furl file so we can check that the contents
376        # don't change on restart
377        furl = fileutil.read(tahoe.introducer_furl_file.path)
378
379        tahoe.active()
380
381        self.assertTrue(tahoe.twistd_pid_file.exists())
382        self.assertTrue(tahoe.node_url_file.exists())
383
384        # rm this so we can detect when the second incarnation is ready
385        tahoe.node_url_file.remove()
386
387        yield tahoe.stop_and_wait()
388
389        p = Expect()
390        tahoe.run(on_stdout(p))
391        yield p.expect(b"introducer running")
392
393        # Again, the second incarnation of the node might not be ready yet, so
394        # poll until it is. This time introducer_furl_file already exists, so
395        # we check for the existence of node_url_file instead.
396        yield self.poll(tahoe.node_url_file.exists)
397
398        # The point of this test!  After starting the second time the
399        # introducer furl file must exist and contain the same contents as it
400        # did before.
401        self.assertTrue(tahoe.introducer_furl_file.exists())
402        self.assertEqual(furl, fileutil.read(tahoe.introducer_furl_file.path))
403
404    @inline_callbacks
405    def test_client(self):
406        """
407        Test too many things.
408
409        0) Verify that "tahoe create-node" takes a --webport option and writes
410           the value to the configuration file.
411
412        1) Verify that "tahoe run" writes a pid file and a node url file (on POSIX).
413
414        2) Verify that the storage furl file has a stable value across a
415           "tahoe run" / stop / "tahoe run" sequence.
416
417        3) Verify that the pid file is removed after SIGTERM (on POSIX).
418        """
419        basedir = self.workdir("test_client")
420        c1 = os.path.join(basedir, u"c1")
421
422        tahoe = CLINodeAPI(reactor, FilePath(c1))
423        # Set this up right now so we don't forget later.
424        self.addCleanup(tahoe.cleanup)
425
426        out, err, returncode = run_bintahoe([
427            u"--quiet", u"create-node", u"--basedir", c1,
428            u"--webport", u"0",
429            u"--hostname", u"localhost",
430        ])
431        self.failUnlessEqual(returncode, 0)
432
433        # Check that the --webport option worked.
434        config = fileutil.read(tahoe.config_file.path).decode('utf-8')
435        self.assertIn(
436            '{}web.port = 0{}'.format(linesep, linesep),
437            config,
438        )
439
440        # After this it's safe to start the node
441        tahoe.active()
442
443        p = Expect()
444        # This will run until we stop it.
445        tahoe.run(on_stdout(p))
446        # Wait for startup to have proceeded to a reasonable point.
447        yield p.expect(b"client running")
448        tahoe.active()
449
450        # read the storage.furl file so we can check that its contents don't
451        # change on restart
452        storage_furl = fileutil.read(tahoe.storage_furl_file.path)
453
454        self.assertTrue(tahoe.twistd_pid_file.exists())
455
456        # rm this so we can detect when the second incarnation is ready
457        tahoe.node_url_file.remove()
458        yield tahoe.stop_and_wait()
459
460        p = Expect()
461        # We don't have to add another cleanup for this one, the one from
462        # above is still registered.
463        tahoe.run(on_stdout(p))
464        yield p.expect(b"client running")
465        tahoe.active()
466
467        self.assertEqual(
468            storage_furl,
469            fileutil.read(tahoe.storage_furl_file.path),
470        )
471
472        self.assertTrue(
473            tahoe.twistd_pid_file.exists(),
474            "PID file ({}) didn't exist when we expected it to.  "
475            "These exist: {}".format(
476                tahoe.twistd_pid_file,
477                tahoe.twistd_pid_file.parent().listdir(),
478            ),
479        )
480        yield tahoe.stop_and_wait()
481
482        # twistd.pid should be gone by now -- except on Windows, where
483        # killing a subprocess immediately exits with no chance for
484        # any shutdown code (that is, no Twisted shutdown hooks can
485        # run).
486        if not platform.isWindows():
487            self.assertFalse(tahoe.twistd_pid_file.exists())
488
489    def _remove(self, res, file):
490        fileutil.remove(file)
491        return res
492
493    def test_run_bad_directory(self):
494        """
495        If ``tahoe run`` is pointed at a non-node directory, it reports an error
496        and exits.
497        """
498        return self._bad_directory_test(
499            u"test_run_bad_directory",
500            "tahoe run",
501            lambda tahoe, p: tahoe.run(p),
502            "is not a recognizable node directory",
503        )
504
505    def test_run_bogus_directory(self):
506        """
507        If ``tahoe run`` is pointed at a non-directory, it reports an error and
508        exits.
509        """
510        return self._bad_directory_test(
511            u"test_run_bogus_directory",
512            "tahoe run",
513            lambda tahoe, p: CLINodeAPI(
514                tahoe.reactor,
515                tahoe.basedir.sibling(u"bogus"),
516            ).run(p),
517            "does not look like a directory at all"
518        )
519
520    @inline_callbacks
521    def _bad_directory_test(self, workdir, description, operation, expected_message):
522        """
523        Verify that a certain ``tahoe`` CLI operation produces a certain expected
524        message and then exits.
525
526        :param unicode workdir: A distinct path name for this test to operate
527            on.
528
529        :param unicode description: A description of the operation being
530            performed.
531
532        :param operation: A two-argument callable implementing the operation.
533            The first argument is a ``CLINodeAPI`` instance to use to perform
534            the operation.  The second argument is an ``IProcessProtocol`` to
535            which the operations output must be delivered.
536
537        :param unicode expected_message: Some text that is expected in the
538            stdout or stderr of the operation in the successful case.
539
540        :return: A ``Deferred`` that fires when the assertions have been made.
541        """
542        basedir = self.workdir(workdir)
543        fileutil.make_dirs(basedir)
544
545        tahoe = CLINodeAPI(reactor, FilePath(basedir))
546        # If tahoe ends up thinking it should keep running, make sure it stops
547        # promptly when the test is done.
548        self.addCleanup(tahoe.cleanup)
549
550        p = Expect()
551        operation(tahoe, on_stdout_and_stderr(p))
552
553        client_running = p.expect(b"client running")
554
555        result, index = yield DeferredList([
556            p.expect(expected_message.encode('utf-8')),
557            client_running,
558        ], fireOnOneCallback=True, consumeErrors=True,
559        )
560
561        self.assertEqual(
562            index,
563            0,
564            "Expected error message from '{}', got something else: {}".format(
565                description,
566                str(p.get_buffered_output(), "utf-8"),
567            ),
568        )
569
570        # It should not be running (but windows shutdown can't run
571        # code so the PID file still exists there).
572        if not platform.isWindows():
573            self.assertFalse(tahoe.twistd_pid_file.exists())
574
575        # Wait for the operation to *complete*.  If we got this far it's
576        # because we got the expected message so we can expect the "tahoe ..."
577        # child process to exit very soon.  This other Deferred will fail when
578        # it eventually does but DeferredList above will consume the error.
579        # What's left is a perfect indicator that the process has exited and
580        # we won't get blamed for leaving the reactor dirty.
581        yield client_running
582
583
584def _simulate_windows_stdin_close(stdio):
585    """
586    on Unix we can just close all the readers, correctly "simulating"
587    a stdin close .. of course, Windows has to be difficult
588    """
589    stdio.writeConnectionLost()
590    stdio.readConnectionLost()
591
592
593class OnStdinCloseTests(SyncTestCase):
594    """
595    Tests for on_stdin_close
596    """
597
598    def test_close_called(self):
599        """
600        our on-close method is called when stdin closes
601        """
602        reactor = MemoryReactorClock()
603        called = []
604
605        def onclose():
606            called.append(True)
607        transport = on_stdin_close(reactor, onclose)
608        self.assertEqual(called, [])
609
610        if platform.isWindows():
611            _simulate_windows_stdin_close(transport)
612        else:
613            for reader in reactor.getReaders():
614                reader.loseConnection()
615            reactor.advance(1)  # ProcessReader does a callLater(0, ..)
616
617        self.assertEqual(called, [True])
618
619    def test_exception_ignored(self):
620        """
621        An exception from our on-close function is discarded.
622        """
623        reactor = MemoryReactorClock()
624        called = []
625
626        def onclose():
627            called.append(True)
628            raise RuntimeError("unexpected error")
629        transport = on_stdin_close(reactor, onclose)
630        self.assertEqual(called, [])
631
632        if platform.isWindows():
633            _simulate_windows_stdin_close(transport)
634        else:
635            for reader in reactor.getReaders():
636                reader.loseConnection()
637            reactor.advance(1)  # ProcessReader does a callLater(0, ..)
638
639        self.assertEqual(called, [True])
640
641
642class PidFileLocking(SyncTestCase):
643    """
644    Direct tests for allmydata.util.pid functions
645    """
646
647    def test_locking(self):
648        """
649        Fail to create a pidfile if another process has the lock already.
650        """
651        # this can't just be "our" process because the locking library
652        # allows the same process to acquire a lock multiple times.
653        pidfile = FilePath(self.mktemp())
654        lockfile = _pidfile_to_lockpath(pidfile)
655
656        with open("other_lock.py", "w") as f:
657            f.write(
658                "\n".join([
659                    "import filelock, time, sys",
660                    "with filelock.FileLock(sys.argv[1], timeout=1):",
661                    "    sys.stdout.write('.\\n')",
662                    "    sys.stdout.flush()",
663                    "    time.sleep(10)",
664                ])
665            )
666        proc = Popen(
667            [sys.executable, "other_lock.py", lockfile.path],
668            stdout=PIPE,
669            stderr=PIPE,
670        )
671        # make sure our subprocess has had time to acquire the lock
672        # for sure (from the "." it prints)
673        proc.stdout.read(2)
674
675        # acquiring the same lock should fail; it is locked by the subprocess
676        with self.assertRaises(ProcessInTheWay):
677            check_pid_process(pidfile)
678        proc.terminate()
Note: See TracBrowser for help on using the repository browser.