Mercurial > libervia-backend
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() |