changeset 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 730f542e4ad0
children 22cd3094cd1e
files libervia/backend/core/launcher.py
diffstat 1 files changed, 133 insertions(+), 41 deletions(-) [+]
line wrap: on
line diff
--- a/libervia/backend/core/launcher.py	Wed Dec 13 22:00:22 2023 +0100
+++ b/libervia/backend/core/launcher.py	Wed Dec 13 22:00:25 2023 +0100
@@ -29,7 +29,6 @@
 
 
 class LiberviaLogger(app.AppLogger):
-
     def start(self, application):
         # logging is initialised by libervia.baceknd.core.log_config via the Twisted
         # plugin, nothing to do here
@@ -40,8 +39,8 @@
 
 
 class Launcher:
-    APP_NAME=C.APP_NAME
-    APP_NAME_FILE=C.APP_NAME_FILE
+    APP_NAME = C.APP_NAME
+    APP_NAME_FILE = C.APP_NAME_FILE
 
     @property
     def NOT_RUNNING_MSG(self):
@@ -56,14 +55,15 @@
         self.run_twistd(args)
 
     def cmd_foreground(self, args):
-        self.run_twistd(args, twistd_opts=['--nodaemon'])
+        self.run_twistd(args, twistd_opts=["--nodaemon"])
 
     def cmd_debug(self, args):
-        self.run_twistd(args, twistd_opts=['--debug'])
+        self.run_twistd(args, twistd_opts=["--debug"])
 
     def cmd_stop(self, args):
         import signal
         import time
+
         config = self.get_config()
         pid_file = self.get_pid_file(config)
         if not pid_file.is_file():
@@ -80,6 +80,7 @@
         kill_started = time.time()
         state = "init"
         import errno
+
         while True:
             try:
                 os.kill(pid, 0)
@@ -87,18 +88,21 @@
                 if e.errno == errno.ESRCH:
                     break
                 elif e.errno == errno.EPERM:
-                    print(f"Can't kill {self.APP_NAME}, the process is owned by an other user", file=sys.stderr)
+                    print(
+                        f"Can't kill {self.APP_NAME}, the process is owned by an other user",
+                        file=sys.stderr,
+                    )
                     sys.exit(18)
                 else:
                     raise e
             time.sleep(0.2)
             now = time.time()
-            if state == 'init' and now - kill_started > 5:
-                if state == 'init':
-                    state = 'waiting'
+            if state == "init" and now - kill_started > 5:
+                if state == "init":
+                    state = "waiting"
                     print(f"Still waiting for {self.APP_NAME} to be terminated…")
-            elif state == 'waiting' and now - kill_started > 10:
-                state == 'killing'
+            elif state == "waiting" and now - kill_started > 10:
+                state == "killing"
                 print("Waiting for too long, we kill the process")
                 os.kill(pid, signal.SIGKILL)
                 sys.exit(1)
@@ -110,6 +114,7 @@
         pid_file = self.get_pid_file(config)
         if pid_file.is_file():
             import errno
+
             try:
                 pid = int(pid_file.read_text())
             except Exception as e:
@@ -133,7 +138,9 @@
                 print(f"{self.APP_NAME} is running (pid: {pid})")
                 sys.exit(0)
             else:
-                print(f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}")
+                print(
+                    f"{self.NOT_RUNNING_MSG}, but a pid file is present (bad exit ?): {pid_file}"
+                )
                 sys.exit(2)
         else:
             print(self.NOT_RUNNING_MSG)
@@ -146,34 +153,35 @@
         extra_help = f"arguments to pass to {self.APP_NAME} service"
 
         bg_parser = subparsers.add_parser(
-            'background',
-            aliases=['bg'],
-            help=f"run {self.APP_NAME} backend in background (as a daemon)")
-        bg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+            "background",
+            aliases=["bg"],
+            help=f"run {self.APP_NAME} backend in background (as a daemon)",
+        )
+        bg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
         bg_parser.set_defaults(cmd=self.cmd_background)
 
         fg_parser = subparsers.add_parser(
-            'foreground',
-            aliases=['fg'],
-            help=f"run {self.APP_NAME} backend in foreground")
-        fg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+            "foreground",
+            aliases=["fg"],
+            help=f"run {self.APP_NAME} backend in foreground",
+        )
+        fg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
         fg_parser.set_defaults(cmd=self.cmd_foreground)
 
         dbg_parser = subparsers.add_parser(
-            'debug',
-            aliases=['dbg'],
-            help=f"run {self.APP_NAME} backend in debug mode")
-        dbg_parser.add_argument('extra_args', nargs=argparse.REMAINDER, help=extra_help)
+            "debug", aliases=["dbg"], help=f"run {self.APP_NAME} backend in debug mode"
+        )
+        dbg_parser.add_argument("extra_args", nargs=argparse.REMAINDER, help=extra_help)
         dbg_parser.set_defaults(cmd=self.cmd_debug)
 
         stop_parser = subparsers.add_parser(
-            'stop',
-            help=f"stop running {self.APP_NAME} backend")
+            "stop", help=f"stop running {self.APP_NAME} backend"
+        )
         stop_parser.set_defaults(cmd=self.cmd_stop)
 
         status_parser = subparsers.add_parser(
-            'status',
-            help=f"indicate if {self.APP_NAME} backend is running")
+            "status", help=f"indicate if {self.APP_NAME} backend is running"
+        )
         status_parser.set_defaults(cmd=self.cmd_status)
 
         return parser.parse_args()
@@ -183,41 +191,125 @@
         try:
             config.read(C.CONFIG_FILES)
         except Exception as e:
-            print (rf"/!\ Can't read main config! {e}")
+            print(rf"/!\ Can't read main config! {e}")
             sys.exit(1)
         return config
 
     def get_pid_file(self, config):
-        pid_dir = Path(config.get('DEFAULT', 'pid_dir')).expanduser()
+        pid_dir = Path(config.get("DEFAULT", "pid_dir")).expanduser()
         return pid_dir / f"{self.APP_NAME_FILE}.pid"
 
+    def wait_for_service(
+        self,
+        service_host: str,
+        service_port: int,
+        timeout: int,
+        service_name: str
+    ) -> None:
+        """Waits for a network service to become available.
+
+        @param service_host: The hostname or IP address of the service.
+        @param service_port: The port number of the service.
+        @param timeout: The maximum number of seconds to wait for the service.
+        @param service_name: The name of the service.
+
+        @raise TimeoutError: If the service is not available within the specified timeout.
+        """
+        import socket
+        import time
+
+        start_time = time.time()
+        wait_interval = 5
+
+        while True:
+            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+                sock.settimeout(1)
+                try:
+                    sock.connect((service_host, service_port))
+                    return
+                except socket.error:
+                    elapsed_time = time.time() - start_time
+                    if elapsed_time % wait_interval < 1:
+                        print(f"Waiting for {service_name}…")
+                    if elapsed_time > timeout:
+                        raise TimeoutError(
+                            f"{service_name} on {service_host}:{service_port} not "
+                            f"available after {timeout} seconds."
+                        )
+                    time.sleep(1)
+
     def run_twistd(self, args, twistd_opts=None):
         """Run twistd settings options with args"""
         from twisted.python.runtime import platformType
+
         if platformType == "win32":
-            from twisted.scripts._twistw import (ServerOptions,
-                                                 WindowsApplicationRunner as app_runner)
+            from twisted.scripts._twistw import (
+                ServerOptions,
+                WindowsApplicationRunner as app_runner,
+            )
         else:
-            from twisted.scripts._twistd_unix import (ServerOptions,
-                                                      UnixApplicationRunner as app_runner)
+            from twisted.scripts._twistd_unix import (
+                ServerOptions,
+                UnixApplicationRunner as app_runner,
+            )
 
         app_runner.loggerFactory = LiberviaLogger
         server_options = ServerOptions()
         config = self.get_config()
+
+        # wait for a service (e.g. XMPP server)
+        wait_for_service_value = config.get(
+            "DEFAULT", "init_wait_for_service", fallback=None
+        )
+
+        if wait_for_service_value is not None:
+            try:
+                # Syntax: [ipv6_address]:port[:timeout][:service_name]
+                # or hostname:port[:timeout][:service_name]
+                parts = wait_for_service_value.split(":")
+                if parts[0] and parts[0][0] == "[" and parts[0][-1] == "]":
+                    # IPv6 address
+                    host = parts[0][1:-1]
+                else:
+                    # Hostname or IPv4
+                    host = parts[0]
+
+                port = int(parts[1])
+
+                # Defaults
+                timeout = 60
+                service_name = "service"
+
+                if len(parts) > 2:
+                    timeout_part = parts[2]
+                    # Check if timeout is skipped (double colon for service_name)
+                    if timeout_part:
+                        timeout = int(timeout_part)
+                    if len(parts) > 3:
+                        service_name = parts[3]
+            except (ValueError, IndexError):
+                raise ValueError(
+                    f'Invalid "init_wait_for_service" value: {wait_for_service_value!r}'
+                )
+            else:
+                self.wait_for_service(host, port, timeout, service_name)
+
         pid_file = self.get_pid_file(config)
-        log_dir = Path(config.get('DEFAULT', 'log_dir')).expanduser()
+        log_dir = Path(config.get("DEFAULT", "log_dir")).expanduser()
         log_file = log_dir / f"{self.APP_NAME_FILE}.log"
         server_opts = [
-            '--no_save',
-            '--pidfile', str(pid_file),
-            '--logfile', str(log_file),
-            ]
+            "--no_save",
+            "--pidfile",
+            str(pid_file),
+            "--logfile",
+            str(log_file),
+        ]
         if twistd_opts is not None:
             server_opts.extend(twistd_opts)
         server_opts.append(self.APP_NAME_FILE)
         if args.extra_args:
             try:
-                args.extra_args.remove('--')
+                args.extra_args.remove("--")
             except ValueError:
                 pass
             server_opts.extend(args.extra_args)
@@ -243,5 +335,5 @@
         args.cmd(args)
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     Launcher.run()