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