Rewrite integration test suite in Python
[tinc] / test / integration / testlib / proc.py
1 """Classes for working with compiled instances of tinc and tincd binaries."""
2
3 import os
4 import random
5 import typing as T
6 import subprocess as subp
7 from enum import Enum
8 from platform import system
9
10 from . import check, path
11 from .log import log
12 from .script import TincScript, Script, ScriptType
13 from .template import make_script, make_cmd_wrap
14 from .util import random_string, random_port
15
16 # Does the OS support all addresses in 127.0.0.0/8 without additional configuration?
17 _FULL_LOCALHOST_SUBNET = system() in ("Linux", "Windows")
18
19
20 def _make_wd(name: str) -> str:
21     work_dir = os.path.join(path.TEST_WD, "data", name)
22     os.makedirs(work_dir, exist_ok=True)
23     return work_dir
24
25
26 def _random_octet() -> int:
27     return random.randint(1, 254)
28
29
30 def _rand_localhost() -> str:
31     """Generate random IP in subnet 127.0.0.0/8 for operating systems that support
32     it without additional configuration. For all others, return 127.0.0.1.
33     """
34     if _FULL_LOCALHOST_SUBNET:
35         return f"127.{_random_octet()}.{_random_octet()}.{_random_octet()}"
36     return "127.0.0.1"
37
38
39 class Feature(Enum):
40     """Optional features supported by both tinc and tincd."""
41
42     COMP_LZ4 = "comp_lz4"
43     COMP_LZO = "comp_lzo"
44     COMP_ZLIB = "comp_zlib"
45     CURSES = "curses"
46     JUMBOGRAMS = "jumbograms"
47     LEGACY_PROTOCOL = "legacy_protocol"
48     LIBGCRYPT = "libgcrypt"
49     MINIUPNPC = "miniupnpc"
50     OPENSSL = "openssl"
51     READLINE = "readline"
52     TUNEMU = "tunemu"
53     UML = "uml"
54     VDE = "vde"
55
56
57 class Tinc:
58     """Thin wrapper around Popen that simplifies running tinc/tincd
59     binaries by passing required arguments, checking exit codes, etc.
60     """
61
62     name: str
63     address: str
64     _work_dir: str
65     _port: T.Optional[int]
66     _scripts: T.Dict[str, TincScript]
67     _procs: T.List[subp.Popen]
68
69     def __init__(self, name: str = "", addr: str = "") -> None:
70         self.name = name if name else random_string(10)
71         self.address = addr if addr else _rand_localhost()
72         self._work_dir = _make_wd(self.name)
73         self._port = None
74         self._scripts = {}
75         self._procs = []
76
77     def randomize_port(self) -> int:
78         """Use a random port for this node."""
79         self._port = random_port()
80         return self._port
81
82     def read_port(self) -> int:
83         """Read port used by tincd from its pidfile and update the _port field."""
84         pidfile = self.sub("pid")
85         log.debug("reading pidfile at %s", pidfile)
86
87         with open(pidfile, "r", encoding="utf-8") as f:
88             content = f.read()
89         log.debug("found data %s", content)
90
91         _, _, _, token, port = content.split()
92         check.equals("port", token)
93
94         self._port = int(port)
95         return self._port
96
97     @property
98     def port(self) -> int:
99         """Port that tincd is listening on."""
100         assert self._port is not None
101         return self._port
102
103     def __str__(self) -> str:
104         return self.name
105
106     def __getitem__(self, script: ScriptType) -> TincScript:
107         if isinstance(script, Script):
108             script = script.name
109         return self._scripts[script]
110
111     def __enter__(self):
112         return self
113
114     def __exit__(self, exc_type, exc_val, exc_tb):
115         self.cleanup()
116
117     @property
118     def features(self) -> T.List[Feature]:
119         """List of features supported by tinc and tincd."""
120         tinc, _ = self.cmd("--version")
121         tincd, _ = self.tincd("--version").communicate(timeout=5)
122         prefix, features = "Features: ", []
123
124         for out in tinc, tincd:
125             for line in out.splitlines():
126                 if not line.startswith(prefix):
127                     continue
128                 tokens = line[len(prefix) :].split()
129                 for token in tokens:
130                     features.append(Feature(token))
131                 break
132
133         log.info('supported features: "%s"', features)
134         return features
135
136     @property
137     def _common_args(self) -> T.List[str]:
138         return [
139             "--net",
140             self.name,
141             "--config",
142             self._work_dir,
143             "--pidfile",
144             self.sub("pid"),
145         ]
146
147     def sub(self, *paths: str) -> str:
148         """Return path to a subdirectory within the working dir for this node."""
149         return os.path.join(self._work_dir, *paths)
150
151     @property
152     def script_up(self) -> str:
153         """Name of the hosts/XXX-up script for this node."""
154         return f"hosts/{self.name}-up"
155
156     @property
157     def script_down(self) -> str:
158         """Name of the hosts/XXX-down script for this node."""
159         return f"hosts/{self.name}-down"
160
161     def cleanup(self) -> None:
162         """Terminate all tinc and tincd processes started from this instance."""
163         log.info("running node cleanup for %s", self)
164
165         try:
166             self.cmd("stop")
167         except (AssertionError, ValueError):
168             log.info("unsuccessfully tried to stop node %s", self)
169
170         for proc in self._procs:
171             if proc.returncode is not None:
172                 log.debug("PID %d exited, skipping", proc.pid)
173             else:
174                 log.info("PID %d still running, stopping", proc.pid)
175                 try:
176                     proc.kill()
177                 except OSError as ex:
178                     log.error("could not kill PID %d", proc.pid, exc_info=ex)
179
180             log.debug("waiting on %d to prevent zombies", proc.pid)
181             try:
182                 proc.wait()
183             except OSError as ex:
184                 log.error("waiting on %d failed", proc.pid, exc_info=ex)
185
186         self._procs.clear()
187
188     def start(self, *args: str) -> int:
189         """Start the node, wait for it to call tinc-up, and get the port it's
190         listening on from the pid file. Don't use this method unless you need
191         to know the port tincd is running on. Call .cmd("start"), it's faster.
192
193         Reading pidfile and setting the port cannot be done from tinc-up because
194         you can't send tinc commands to yourself there — the daemon doesn't
195         respond to them until tinc-up is finished. The port field on this Tinc
196         instance is updated to reflect the correct port. If tinc-up is missing,
197         this command creates a new one, and then disables it.
198         """
199         new_script = Script.TINC_UP.name not in self._scripts
200         if new_script:
201             self.add_script(Script.TINC_UP)
202
203         tinc_up = self[Script.TINC_UP]
204         self.cmd(*args, "start")
205         tinc_up.wait()
206
207         if new_script:
208             tinc_up.disable()
209
210         self._port = self.read_port()
211         self.cmd("set", "Port", str(self._port))
212
213         return self._port
214
215     def cmd(
216         self, *args: str, code: T.Optional[int] = 0, stdin: T.Optional[str] = None
217     ) -> T.Tuple[str, str]:
218         """Run command through tinc, writes `stdin` to it (if the argument is not None),
219         check its return code (if the argument is not None), and return (stdout, stderr).
220         """
221         proc = self.tinc(*args)
222         log.debug('tinc %s: PID %d, in "%s", want code %s', self, proc.pid, stdin, code)
223
224         out, err = proc.communicate(stdin, timeout=60)
225         res = proc.returncode
226         self._procs.remove(proc)
227         log.debug('tinc %s: code %d, out "%s", err "%s"', self, res, out, err)
228
229         if code is not None:
230             check.equals(code, res)
231
232         # Check that port was not used by something else
233         check.not_in("Can't bind to ", err)
234
235         return out if out else "", err if err else ""
236
237     def tinc(self, *args: str) -> subp.Popen:
238         """Start tinc with the specified arguments."""
239         args = tuple(filter(bool, args))
240         cmd = [path.TINC_PATH, *self._common_args, *args]
241         log.debug('starting tinc %s: "%s"', self.name, " ".join(cmd))
242         # pylint: disable=consider-using-with
243         proc = subp.Popen(
244             cmd,
245             cwd=self._work_dir,
246             stdin=subp.PIPE,
247             stdout=subp.PIPE,
248             stderr=subp.PIPE,
249             encoding="utf-8",
250         )
251         self._procs.append(proc)
252         return proc
253
254     def tincd(self, *args: str, env: T.Optional[T.Dict[str, str]] = None) -> subp.Popen:
255         """Start tincd with the specified arguments."""
256         args = tuple(filter(bool, args))
257         cmd = [
258             path.TINCD_PATH,
259             *self._common_args,
260             "--logfile",
261             self.sub("log"),
262             "-d5",
263             *args,
264         ]
265         log.debug('starting tincd %s: "%s"', self.name, " ".join(cmd))
266         if env is not None:
267             env = {**os.environ, **env}
268         # pylint: disable=consider-using-with
269         proc = subp.Popen(
270             cmd,
271             cwd=self._work_dir,
272             stdin=subp.PIPE,
273             stdout=subp.PIPE,
274             stderr=subp.PIPE,
275             encoding="utf-8",
276             env=env,
277         )
278         self._procs.append(proc)
279         return proc
280
281     def add_script(self, script: ScriptType, source: str = "") -> TincScript:
282         """Create a script with the passed Python source code.
283         The source must either be empty, or start indentation with 4 spaces.
284         If the source is empty, the created script can be used to receive notifications.
285         """
286         rel_path = script if isinstance(script, str) else script.value
287         check.not_in(rel_path, self._scripts)
288
289         full_path = os.path.join(self._work_dir, rel_path)
290         tinc_script = TincScript(self.name, rel_path, full_path)
291
292         log.debug("creating script %s at %s", script, full_path)
293         with open(full_path, "w", encoding="utf-8") as f:
294             content = make_script(self.name, rel_path, source)
295             f.write(content)
296
297         if os.name == "nt":
298             log.debug("creating .cmd script wrapper at %s", full_path)
299             win_content = make_cmd_wrap(full_path)
300             with open(f"{full_path}.cmd", "w", encoding="utf-8") as f:
301                 f.write(win_content)
302         else:
303             os.chmod(full_path, 0o755)
304
305         if isinstance(script, Script):
306             self._scripts[script.name] = tinc_script
307         self._scripts[rel_path] = tinc_script
308
309         return tinc_script