| 1 | """ |
|---|
| 2 | Tests for ``allmydata.scripts.tahoe_run``. |
|---|
| 3 | """ |
|---|
| 4 | |
|---|
| 5 | from __future__ import annotations |
|---|
| 6 | |
|---|
| 7 | import re |
|---|
| 8 | from io import ( |
|---|
| 9 | StringIO, |
|---|
| 10 | ) |
|---|
| 11 | |
|---|
| 12 | from hypothesis.strategies import text |
|---|
| 13 | from hypothesis import given, assume |
|---|
| 14 | |
|---|
| 15 | from testtools.matchers import ( |
|---|
| 16 | Contains, |
|---|
| 17 | Equals, |
|---|
| 18 | ) |
|---|
| 19 | |
|---|
| 20 | from twisted.python.filepath import ( |
|---|
| 21 | FilePath, |
|---|
| 22 | ) |
|---|
| 23 | from twisted.internet.testing import ( |
|---|
| 24 | MemoryReactor, |
|---|
| 25 | ) |
|---|
| 26 | from twisted.python.failure import ( |
|---|
| 27 | Failure, |
|---|
| 28 | ) |
|---|
| 29 | from twisted.internet.error import ( |
|---|
| 30 | ConnectionDone, |
|---|
| 31 | ) |
|---|
| 32 | from twisted.internet.test.modulehelpers import ( |
|---|
| 33 | AlternateReactor, |
|---|
| 34 | ) |
|---|
| 35 | |
|---|
| 36 | from ...scripts.tahoe_run import ( |
|---|
| 37 | DaemonizeTheRealService, |
|---|
| 38 | RunOptions, |
|---|
| 39 | run, |
|---|
| 40 | ) |
|---|
| 41 | from ...util.pid import ( |
|---|
| 42 | check_pid_process, |
|---|
| 43 | InvalidPidFile, |
|---|
| 44 | ) |
|---|
| 45 | |
|---|
| 46 | from ...scripts.runner import ( |
|---|
| 47 | parse_options |
|---|
| 48 | ) |
|---|
| 49 | from ..common import ( |
|---|
| 50 | SyncTestCase, |
|---|
| 51 | ) |
|---|
| 52 | |
|---|
| 53 | class DaemonizeTheRealServiceTests(SyncTestCase): |
|---|
| 54 | """ |
|---|
| 55 | Tests for ``DaemonizeTheRealService``. |
|---|
| 56 | """ |
|---|
| 57 | def _verify_error(self, config, expected): |
|---|
| 58 | """ |
|---|
| 59 | Assert that when ``DaemonizeTheRealService`` is started using the given |
|---|
| 60 | configuration it writes the given message to stderr and stops the |
|---|
| 61 | reactor. |
|---|
| 62 | |
|---|
| 63 | :param bytes config: The contents of a ``tahoe.cfg`` file to give to |
|---|
| 64 | the service. |
|---|
| 65 | |
|---|
| 66 | :param bytes expected: A string to assert appears in stderr after the |
|---|
| 67 | service starts. |
|---|
| 68 | """ |
|---|
| 69 | nodedir = FilePath(self.mktemp()) |
|---|
| 70 | nodedir.makedirs() |
|---|
| 71 | nodedir.child("tahoe.cfg").setContent(config.encode("ascii")) |
|---|
| 72 | nodedir.child("tahoe-client.tac").touch() |
|---|
| 73 | |
|---|
| 74 | options = parse_options(["run", nodedir.path]) |
|---|
| 75 | stdout = options.stdout = StringIO() |
|---|
| 76 | stderr = options.stderr = StringIO() |
|---|
| 77 | run_options = options.subOptions |
|---|
| 78 | |
|---|
| 79 | reactor = MemoryReactor() |
|---|
| 80 | with AlternateReactor(reactor): |
|---|
| 81 | service = DaemonizeTheRealService( |
|---|
| 82 | "client", |
|---|
| 83 | nodedir.path, |
|---|
| 84 | run_options, |
|---|
| 85 | ) |
|---|
| 86 | service.startService() |
|---|
| 87 | |
|---|
| 88 | # We happen to know that the service uses reactor.callWhenRunning |
|---|
| 89 | # to schedule all its work (though I couldn't tell you *why*). |
|---|
| 90 | # Make sure those scheduled calls happen. |
|---|
| 91 | waiting = reactor.whenRunningHooks[:] |
|---|
| 92 | del reactor.whenRunningHooks[:] |
|---|
| 93 | for f, a, k in waiting: |
|---|
| 94 | f(*a, **k) |
|---|
| 95 | |
|---|
| 96 | self.assertThat( |
|---|
| 97 | reactor.hasStopped, |
|---|
| 98 | Equals(True), |
|---|
| 99 | ) |
|---|
| 100 | |
|---|
| 101 | self.assertThat( |
|---|
| 102 | stdout.getvalue(), |
|---|
| 103 | Equals(""), |
|---|
| 104 | ) |
|---|
| 105 | |
|---|
| 106 | self.assertThat( |
|---|
| 107 | stderr.getvalue(), |
|---|
| 108 | Contains(expected), |
|---|
| 109 | ) |
|---|
| 110 | |
|---|
| 111 | def test_unknown_config(self): |
|---|
| 112 | """ |
|---|
| 113 | If there are unknown items in the node configuration file then a short |
|---|
| 114 | message introduced with ``"Configuration error:"`` is written to |
|---|
| 115 | stderr. |
|---|
| 116 | """ |
|---|
| 117 | self._verify_error("[invalid-section]\n", "Configuration error:") |
|---|
| 118 | |
|---|
| 119 | def test_port_assignment_required(self): |
|---|
| 120 | """ |
|---|
| 121 | If ``tub.port`` is configured to use port 0 then a short message rejecting |
|---|
| 122 | this configuration is written to stderr. |
|---|
| 123 | """ |
|---|
| 124 | self._verify_error( |
|---|
| 125 | """ |
|---|
| 126 | [node] |
|---|
| 127 | tub.port = 0 |
|---|
| 128 | """, |
|---|
| 129 | "tub.port cannot be 0", |
|---|
| 130 | ) |
|---|
| 131 | |
|---|
| 132 | def test_privacy_error(self): |
|---|
| 133 | """ |
|---|
| 134 | If ``reveal-IP-address`` is set to false and the tub is not configured in |
|---|
| 135 | a way that avoids revealing the node's IP address, a short message |
|---|
| 136 | about privacy is written to stderr. |
|---|
| 137 | """ |
|---|
| 138 | self._verify_error( |
|---|
| 139 | """ |
|---|
| 140 | [node] |
|---|
| 141 | tub.port = AUTO |
|---|
| 142 | reveal-IP-address = false |
|---|
| 143 | """, |
|---|
| 144 | "Privacy requested", |
|---|
| 145 | ) |
|---|
| 146 | |
|---|
| 147 | |
|---|
| 148 | class DaemonizeStopTests(SyncTestCase): |
|---|
| 149 | """ |
|---|
| 150 | Tests relating to stopping the daemon |
|---|
| 151 | """ |
|---|
| 152 | def setUp(self): |
|---|
| 153 | self.nodedir = FilePath(self.mktemp()) |
|---|
| 154 | self.nodedir.makedirs() |
|---|
| 155 | config = "" |
|---|
| 156 | self.nodedir.child("tahoe.cfg").setContent(config.encode("ascii")) |
|---|
| 157 | self.nodedir.child("tahoe-client.tac").touch() |
|---|
| 158 | |
|---|
| 159 | # arrange to know when reactor.stop() is called |
|---|
| 160 | self.reactor = MemoryReactor() |
|---|
| 161 | self.stop_calls = [] |
|---|
| 162 | |
|---|
| 163 | def record_stop(): |
|---|
| 164 | self.stop_calls.append(object()) |
|---|
| 165 | self.reactor.stop = record_stop |
|---|
| 166 | |
|---|
| 167 | super().setUp() |
|---|
| 168 | |
|---|
| 169 | def _make_daemon(self, extra_argv: list[str]) -> DaemonizeTheRealService: |
|---|
| 170 | """ |
|---|
| 171 | Create the daemonization service. |
|---|
| 172 | |
|---|
| 173 | :param extra_argv: Extra arguments to pass between ``run`` and the |
|---|
| 174 | node path. |
|---|
| 175 | """ |
|---|
| 176 | options = parse_options(["run"] + extra_argv + [self.nodedir.path]) |
|---|
| 177 | options.stdout = StringIO() |
|---|
| 178 | options.stderr = StringIO() |
|---|
| 179 | options.stdin = StringIO() |
|---|
| 180 | run_options = options.subOptions |
|---|
| 181 | return DaemonizeTheRealService( |
|---|
| 182 | "client", |
|---|
| 183 | self.nodedir.path, |
|---|
| 184 | run_options, |
|---|
| 185 | ) |
|---|
| 186 | |
|---|
| 187 | def _run_daemon(self) -> None: |
|---|
| 188 | """ |
|---|
| 189 | Simulate starting up the reactor so the daemon plugin can do its |
|---|
| 190 | stuff. |
|---|
| 191 | """ |
|---|
| 192 | # We happen to know that the service uses reactor.callWhenRunning |
|---|
| 193 | # to schedule all its work (though I couldn't tell you *why*). |
|---|
| 194 | # Make sure those scheduled calls happen. |
|---|
| 195 | waiting = self.reactor.whenRunningHooks[:] |
|---|
| 196 | del self.reactor.whenRunningHooks[:] |
|---|
| 197 | for f, a, k in waiting: |
|---|
| 198 | f(*a, **k) |
|---|
| 199 | |
|---|
| 200 | def _close_stdin(self) -> None: |
|---|
| 201 | """ |
|---|
| 202 | Simulate closing the daemon plugin's stdin. |
|---|
| 203 | """ |
|---|
| 204 | # there should be a single reader: our StandardIO process |
|---|
| 205 | # reader for stdin. Simulate it closing. |
|---|
| 206 | for r in self.reactor.getReaders(): |
|---|
| 207 | r.connectionLost(Failure(ConnectionDone())) |
|---|
| 208 | |
|---|
| 209 | def test_stop_on_stdin_close(self): |
|---|
| 210 | """ |
|---|
| 211 | We stop when stdin is closed. |
|---|
| 212 | """ |
|---|
| 213 | with AlternateReactor(self.reactor): |
|---|
| 214 | service = self._make_daemon([]) |
|---|
| 215 | service.startService() |
|---|
| 216 | self._run_daemon() |
|---|
| 217 | self._close_stdin() |
|---|
| 218 | self.assertEqual(len(self.stop_calls), 1) |
|---|
| 219 | |
|---|
| 220 | def test_allow_stdin_close(self): |
|---|
| 221 | """ |
|---|
| 222 | If --allow-stdin-close is specified then closing stdin doesn't |
|---|
| 223 | stop the process |
|---|
| 224 | """ |
|---|
| 225 | with AlternateReactor(self.reactor): |
|---|
| 226 | service = self._make_daemon(["--allow-stdin-close"]) |
|---|
| 227 | service.startService() |
|---|
| 228 | self._run_daemon() |
|---|
| 229 | self._close_stdin() |
|---|
| 230 | self.assertEqual(self.stop_calls, []) |
|---|
| 231 | |
|---|
| 232 | |
|---|
| 233 | class RunTests(SyncTestCase): |
|---|
| 234 | """ |
|---|
| 235 | Tests for ``run``. |
|---|
| 236 | """ |
|---|
| 237 | |
|---|
| 238 | def test_non_numeric_pid(self): |
|---|
| 239 | """ |
|---|
| 240 | If the pidfile exists but does not contain a numeric value, a complaint to |
|---|
| 241 | this effect is written to stderr. |
|---|
| 242 | """ |
|---|
| 243 | basedir = FilePath(self.mktemp()).asTextMode() |
|---|
| 244 | basedir.makedirs() |
|---|
| 245 | basedir.child(u"running.process").setContent(b"foo") |
|---|
| 246 | basedir.child(u"tahoe-client.tac").setContent(b"") |
|---|
| 247 | |
|---|
| 248 | config = RunOptions() |
|---|
| 249 | config.stdout = StringIO() |
|---|
| 250 | config.stderr = StringIO() |
|---|
| 251 | config['basedir'] = basedir.path |
|---|
| 252 | config.twistd_args = [] |
|---|
| 253 | |
|---|
| 254 | reactor = MemoryReactor() |
|---|
| 255 | |
|---|
| 256 | runs = [] |
|---|
| 257 | result_code = run(reactor, config, runApp=runs.append) |
|---|
| 258 | self.assertThat( |
|---|
| 259 | config.stderr.getvalue(), |
|---|
| 260 | Contains("found invalid PID file in"), |
|---|
| 261 | ) |
|---|
| 262 | # because the pidfile is invalid we shouldn't get to the |
|---|
| 263 | # .run() call itself. |
|---|
| 264 | self.assertThat(runs, Equals([])) |
|---|
| 265 | self.assertThat(result_code, Equals(1)) |
|---|
| 266 | |
|---|
| 267 | good_file_content_re = re.compile(r"\s*[0-9]*\s[0-9]*\s*", re.M) |
|---|
| 268 | |
|---|
| 269 | @given(text()) |
|---|
| 270 | def test_pidfile_contents(self, content): |
|---|
| 271 | """ |
|---|
| 272 | invalid contents for a pidfile raise errors |
|---|
| 273 | """ |
|---|
| 274 | assume(not self.good_file_content_re.match(content)) |
|---|
| 275 | pidfile = FilePath("pidfile") |
|---|
| 276 | pidfile.setContent(content.encode("utf8")) |
|---|
| 277 | |
|---|
| 278 | with self.assertRaises(InvalidPidFile): |
|---|
| 279 | with check_pid_process(pidfile): |
|---|
| 280 | pass |
|---|