comparison libervia/backend/core/launcher.py @ 4194:3dbaf179c50d

core (launcher): new `wait_for_service` option: if `wait_for_service` is used in `[DEFAULT]`, the service at the specified host/port is waited for before starting the backend. This is useful in the situation where we need to be sure that something is started before running, e.g. in a container.
author Goffi <goffi@goffi.org>
date Wed, 13 Dec 2023 22:00:25 +0100
parents 84f6bee6440d
children b26339343076
comparison
equal deleted inserted replaced
4193:730f542e4ad0 4194:3dbaf179c50d
27 from twisted.python import usage 27 from twisted.python import usage
28 from libervia.backend.core.constants import Const as C 28 from libervia.backend.core.constants import Const as C
29 29
30 30
31 class LiberviaLogger(app.AppLogger): 31 class LiberviaLogger(app.AppLogger):
32
33 def start(self, application): 32 def start(self, application):
34 # logging is initialised by libervia.baceknd.core.log_config via the Twisted 33 # logging is initialised by libervia.baceknd.core.log_config via the Twisted
35 # plugin, nothing to do here 34 # plugin, nothing to do here
36 self._initialLog() 35 self._initialLog()
37 36
38 def stop(self): 37 def stop(self):
39 pass 38 pass
40 39
41 40
42 class Launcher: 41 class Launcher:
43 APP_NAME=C.APP_NAME 42 APP_NAME = C.APP_NAME
44 APP_NAME_FILE=C.APP_NAME_FILE 43 APP_NAME_FILE = C.APP_NAME_FILE
45 44
46 @property 45 @property
47 def NOT_RUNNING_MSG(self): 46 def NOT_RUNNING_MSG(self):
48 return f"{self.APP_NAME} is *NOT* running" 47 return f"{self.APP_NAME} is *NOT* running"
49 48
54 53
55 def cmd_background(self, args): 54 def cmd_background(self, args):
56 self.run_twistd(args) 55 self.run_twistd(args)
57 56
58 def cmd_foreground(self, args): 57 def cmd_foreground(self, args):
59 self.run_twistd(args, twistd_opts=['--nodaemon']) 58 self.run_twistd(args, twistd_opts=["--nodaemon"])
60 59
61 def cmd_debug(self, args): 60 def cmd_debug(self, args):
62 self.run_twistd(args, twistd_opts=['--debug']) 61 self.run_twistd(args, twistd_opts=["--debug"])
63 62
64 def cmd_stop(self, args): 63 def cmd_stop(self, args):
65 import signal 64 import signal
66 import time 65 import time
66
67 config = self.get_config() 67 config = self.get_config()
68 pid_file = self.get_pid_file(config) 68 pid_file = self.get_pid_file(config)
69 if not pid_file.is_file(): 69 if not pid_file.is_file():
70 print(self.NOT_RUNNING_MSG) 70 print(self.NOT_RUNNING_MSG)
71 sys.exit(0) 71 sys.exit(0)
78 print(f"Terminating {self.APP_NAME}…") 78 print(f"Terminating {self.APP_NAME}…")
79 os.kill(pid, signal.SIGTERM) 79 os.kill(pid, signal.SIGTERM)
80 kill_started = time.time() 80 kill_started = time.time()
81 state = "init" 81 state = "init"
82 import errno 82 import errno
83
83 while True: 84 while True:
84 try: 85 try:
85 os.kill(pid, 0) 86 os.kill(pid, 0)
86 except OSError as e: 87 except OSError as e:
87 if e.errno == errno.ESRCH: 88 if e.errno == errno.ESRCH:
88 break 89 break
89 elif e.errno == errno.EPERM: 90 elif e.errno == errno.EPERM:
90 print(f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr) 91 print(
92 f"Can't kill {self.APP_NAME}, the process is owned by an other user",
93 file=sys.stderr,
94 )
91 sys.exit(18) 95 sys.exit(18)
92 else: 96 else:
93 raise e 97 raise e
94 time.sleep(0.2) 98 time.sleep(0.2)
95 now = time.time() 99 now = time.time()
96 if state == 'init' and now - kill_started > 5: 100 if state == "init" and now - kill_started > 5:
97 if state == 'init': 101 if state == "init":
98 state = 'waiting' 102 state = "waiting"
99 print(f"Still waiting for {self.APP_NAME} to be terminated…") 103 print(f"Still waiting for {self.APP_NAME} to be terminated…")
100 elif state == 'waiting' and now - kill_started > 10: 104 elif state == "waiting" and now - kill_started > 10:
101 state == 'killing' 105 state == "killing"
102 print("Waiting for too long, we kill the process") 106 print("Waiting for too long, we kill the process")
103 os.kill(pid, signal.SIGKILL) 107 os.kill(pid, signal.SIGKILL)
104 sys.exit(1) 108 sys.exit(1)
105 109
106 sys.exit(0) 110 sys.exit(0)
108 def cmd_status(self, args): 112 def cmd_status(self, args):
109 config = self.get_config() 113 config = self.get_config()
110 pid_file = self.get_pid_file(config) 114 pid_file = self.get_pid_file(config)
111 if pid_file.is_file(): 115 if pid_file.is_file():
112 import errno 116 import errno
117
113 try: 118 try:
114 pid = int(pid_file.read_text()) 119 pid = int(pid_file.read_text())
115 except Exception as e: 120 except Exception as e:
116 print(f"Can't read PID file at {pid_file}: {e}") 121 print(f"Can't read PID file at {pid_file}: {e}")
117 # we use the same exit code as DATA_ERROR in CLI frontend 122 # we use the same exit code as DATA_ERROR in CLI frontend
131 136
132 if running: 137 if running:
133 print(f"{self.APP_NAME} is running (pid: {pid})") 138 print(f"{self.APP_NAME} is running (pid: {pid})")
134 sys.exit(0) 139 sys.exit(0)
135 else: 140 else:
136 print(f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}") 141 print(
142 f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}"
143 )
137 sys.exit(2) 144 sys.exit(2)
138 else: 145 else:
139 print(self.NOT_RUNNING_MSG) 146 print(self.NOT_RUNNING_MSG)
140 sys.exit(1) 147 sys.exit(1)
141 148
144 parser.set_defaults(cmd=self.cmd_no_subparser) 151 parser.set_defaults(cmd=self.cmd_no_subparser)
145 subparsers = parser.add_subparsers() 152 subparsers = parser.add_subparsers()
146 extra_help = f"arguments to pass to {self.APP_NAME} service" 153 extra_help = f"arguments to pass to {self.APP_NAME} service"
147 154
148 bg_parser = subparsers.add_parser( 155 bg_parser = subparsers.add_parser(
149 'background', 156 "background",
150 aliases=['bg'], 157 aliases=["bg"],
151 help=f"run {self.APP_NAME} backend in background (as a daemon)") 158 help=f"run {self.APP_NAME} backend in background (as a daemon)",
152 bg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help) 159 )
160 bg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
153 bg_parser.set_defaults(cmd=self.cmd_background) 161 bg_parser.set_defaults(cmd=self.cmd_background)
154 162
155 fg_parser = subparsers.add_parser( 163 fg_parser = subparsers.add_parser(
156 'foreground', 164 "foreground",
157 aliases=['fg'], 165 aliases=["fg"],
158 help=f"run {self.APP_NAME} backend in foreground") 166 help=f"run {self.APP_NAME} backend in foreground",
159 fg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help) 167 )
168 fg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
160 fg_parser.set_defaults(cmd=self.cmd_foreground) 169 fg_parser.set_defaults(cmd=self.cmd_foreground)
161 170
162 dbg_parser = subparsers.add_parser( 171 dbg_parser = subparsers.add_parser(
163 'debug', 172 "debug", aliases=["dbg"], help=f"run {self.APP_NAME} backend in debug mode"
164 aliases=['dbg'], 173 )
165 help=f"run {self.APP_NAME} backend in debug mode") 174 dbg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
166 dbg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
167 dbg_parser.set_defaults(cmd=self.cmd_debug) 175 dbg_parser.set_defaults(cmd=self.cmd_debug)
168 176
169 stop_parser = subparsers.add_parser( 177 stop_parser = subparsers.add_parser(
170 'stop', 178 "stop", help=f"stop running {self.APP_NAME} backend"
171 help=f"stop running {self.APP_NAME} backend") 179 )
172 stop_parser.set_defaults(cmd=self.cmd_stop) 180 stop_parser.set_defaults(cmd=self.cmd_stop)
173 181
174 status_parser = subparsers.add_parser( 182 status_parser = subparsers.add_parser(
175 'status', 183 "status", help=f"indicate if {self.APP_NAME} backend is running"
176 help=f"indicate if {self.APP_NAME} backend is running") 184 )
177 status_parser.set_defaults(cmd=self.cmd_status) 185 status_parser.set_defaults(cmd=self.cmd_status)
178 186
179 return parser.parse_args() 187 return parser.parse_args()
180 188
181 def get_config(self): 189 def get_config(self):
182 config = ConfigParser(defaults=C.DEFAULT_CONFIG) 190 config = ConfigParser(defaults=C.DEFAULT_CONFIG)
183 try: 191 try:
184 config.read(C.CONFIG_FILES) 192 config.read(C.CONFIG_FILES)
185 except Exception as e: 193 except Exception as e:
186 print (rf"/!\ Can't read main config! {e}") 194 print(rf"/!\ Can't read main config! {e}")
187 sys.exit(1) 195 sys.exit(1)
188 return config 196 return config
189 197
190 def get_pid_file(self, config): 198 def get_pid_file(self, config):
191 pid_dir = Path(config.get('DEFAULT', 'pid_dir')).expanduser() 199 pid_dir = Path(config.get("DEFAULT", "pid_dir")).expanduser()
192 return pid_dir / f"{self.APP_NAME_FILE}.pid" 200 return pid_dir / f"{self.APP_NAME_FILE}.pid"
201
202 def wait_for_service(
203 self,
204 service_host: str,
205 service_port: int,
206 timeout: int,
207 service_name: str
208 ) -> None:
209 """Waits for a network service to become available.
210
211 @param service_host: The hostname or IP address of the service.
212 @param service_port: The port number of the service.
213 @param timeout: The maximum number of seconds to wait for the service.
214 @param service_name: The name of the service.
215
216 @raise TimeoutError: If the service is not available within the specified timeout.
217 """
218 import socket
219 import time
220
221 start_time = time.time()
222 wait_interval = 5
223
224 while True:
225 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
226 sock.settimeout(1)
227 try:
228 sock.connect((service_host, service_port))
229 return
230 except socket.error:
231 elapsed_time = time.time() - start_time
232 if elapsed_time % wait_interval < 1:
233 print(f"Waiting for {service_name}…")
234 if elapsed_time > timeout:
235 raise TimeoutError(
236 f"{service_name} on {service_host}:{service_port} not "
237 f"available after {timeout} seconds."
238 )
239 time.sleep(1)
193 240
194 def run_twistd(self, args, twistd_opts=None): 241 def run_twistd(self, args, twistd_opts=None):
195 """Run twistd settings options with args""" 242 """Run twistd settings options with args"""
196 from twisted.python.runtime import platformType 243 from twisted.python.runtime import platformType
244
197 if platformType == "win32": 245 if platformType == "win32":
198 from twisted.scripts._twistw import (ServerOptions, 246 from twisted.scripts._twistw import (
199 WindowsApplicationRunner as app_runner) 247 ServerOptions,
248 WindowsApplicationRunner as app_runner,
249 )
200 else: 250 else:
201 from twisted.scripts._twistd_unix import (ServerOptions, 251 from twisted.scripts._twistd_unix import (
202 UnixApplicationRunner as app_runner) 252 ServerOptions,
253 UnixApplicationRunner as app_runner,
254 )
203 255
204 app_runner.loggerFactory = LiberviaLogger 256 app_runner.loggerFactory = LiberviaLogger
205 server_options = ServerOptions() 257 server_options = ServerOptions()
206 config = self.get_config() 258 config = self.get_config()
259
260 # wait for a service (e.g. XMPP server)
261 wait_for_service_value = config.get(
262 "DEFAULT", "init_wait_for_service", fallback=None
263 )
264
265 if wait_for_service_value is not None:
266 try:
267 # Syntax: [ipv6_address]:port[:timeout][:service_name]
268 # or hostname:port[:timeout][:service_name]
269 parts = wait_for_service_value.split(":")
270 if parts[0] and parts[0][0] == "[" and parts[0][-1] == "]":
271 # IPv6 address
272 host = parts[0][1:-1]
273 else:
274 # Hostname or IPv4
275 host = parts[0]
276
277 port = int(parts[1])
278
279 # Defaults
280 timeout = 60
281 service_name = "service"
282
283 if len(parts) > 2:
284 timeout_part = parts[2]
285 # Check if timeout is skipped (double colon for service_name)
286 if timeout_part:
287 timeout = int(timeout_part)
288 if len(parts) > 3:
289 service_name = parts[3]
290 except (ValueError, IndexError):
291 raise ValueError(
292 f'Invalid "init_wait_for_service" value: {wait_for_service_value!r}'
293 )
294 else:
295 self.wait_for_service(host, port, timeout, service_name)
296
207 pid_file = self.get_pid_file(config) 297 pid_file = self.get_pid_file(config)
208 log_dir = Path(config.get('DEFAULT', 'log_dir')).expanduser() 298 log_dir = Path(config.get("DEFAULT", "log_dir")).expanduser()
209 log_file = log_dir / f"{self.APP_NAME_FILE}.log" 299 log_file = log_dir / f"{self.APP_NAME_FILE}.log"
210 server_opts = [ 300 server_opts = [
211 '--no_save', 301 "--no_save",
212 '--pidfile', str(pid_file), 302 "--pidfile",
213 '--logfile', str(log_file), 303 str(pid_file),
214 ] 304 "--logfile",
305 str(log_file),
306 ]
215 if twistd_opts is not None: 307 if twistd_opts is not None:
216 server_opts.extend(twistd_opts) 308 server_opts.extend(twistd_opts)
217 server_opts.append(self.APP_NAME_FILE) 309 server_opts.append(self.APP_NAME_FILE)
218 if args.extra_args: 310 if args.extra_args:
219 try: 311 try:
220 args.extra_args.remove('--') 312 args.extra_args.remove("--")
221 except ValueError: 313 except ValueError:
222 pass 314 pass
223 server_opts.extend(args.extra_args) 315 server_opts.extend(args.extra_args)
224 try: 316 try:
225 server_options.parseOptions(server_opts) 317 server_options.parseOptions(server_opts)
241 def run(cls): 333 def run(cls):
242 args = cls().parse_args() 334 args = cls().parse_args()
243 args.cmd(args) 335 args.cmd(args)
244 336
245 337
246 if __name__ == '__main__': 338 if __name__ == "__main__":
247 Launcher.run() 339 Launcher.run()