1faea55099ecdb2629c64c4eea0269aa324b5351
[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         return out if out else "", err if err else ""
233
234     def tinc(self, *args: str) -> subp.Popen:
235         """Start tinc with the specified arguments."""
236         args = tuple(filter(bool, args))
237         cmd = [path.TINC_PATH, *self._common_args, *args]
238         log.debug('starting tinc %s: "%s"', self.name, " ".join(cmd))
239         # pylint: disable=consider-using-with
240         proc = subp.Popen(
241             cmd,
242             cwd=self._work_dir,
243             stdin=subp.PIPE,
244             stdout=subp.PIPE,
245             stderr=subp.PIPE,
246             encoding="utf-8",
247         )
248         self._procs.append(proc)
249         return proc
250
251     def tincd(self, *args: str, env: T.Optional[T.Dict[str, str]] = None) -> subp.Popen:
252         """Start tincd with the specified arguments."""
253         args = tuple(filter(bool, args))
254         cmd = [
255             path.TINCD_PATH,
256             *self._common_args,
257             "--logfile",
258             self.sub("log"),
259             "-d5",
260             *args,
261         ]
262         log.debug('starting tincd %s: "%s"', self.name, " ".join(cmd))
263         if env is not None:
264             env = {**os.environ, **env}
265         # pylint: disable=consider-using-with
266         proc = subp.Popen(
267             cmd,
268             cwd=self._work_dir,
269             stdin=subp.PIPE,
270             stdout=subp.PIPE,
271             stderr=subp.PIPE,
272             encoding="utf-8",
273             env=env,
274         )
275         self._procs.append(proc)
276         return proc
277
278     def add_script(self, script: ScriptType, source: str = "") -> TincScript:
279         """Create a script with the passed Python source code.
280         The source must either be empty, or start indentation with 4 spaces.
281         If the source is empty, the created script can be used to receive notifications.
282         """
283         rel_path = script if isinstance(script, str) else script.value
284         check.not_in(rel_path, self._scripts)
285
286         full_path = os.path.join(self._work_dir, rel_path)
287         tinc_script = TincScript(self.name, rel_path, full_path)
288
289         log.debug("creating script %s at %s", script, full_path)
290         with open(full_path, "w", encoding="utf-8") as f:
291             content = make_script(self.name, rel_path, source)
292             f.write(content)
293
294         if os.name == "nt":
295             log.debug("creating .cmd script wrapper at %s", full_path)
296             win_content = make_cmd_wrap(full_path)
297             with open(f"{full_path}.cmd", "w", encoding="utf-8") as f:
298                 f.write(win_content)
299         else:
300             os.chmod(full_path, 0o755)
301
302         if isinstance(script, Script):
303             self._scripts[script.name] = tinc_script
304         self._scripts[rel_path] = tinc_script
305
306         return tinc_script