diff --git a/README.md b/README.md index 35540158..265b5b77 100644 --- a/README.md +++ b/README.md @@ -1553,6 +1553,35 @@ The job manager writes log information to the file `eessi_bot_job_manager.log`. The job manager can run on a different machine than the event handler, as long as both have access to the same shared filesystem. +## Step 7.3: Managing the bot with a single command + +The `bot.py` script starts, stops, restarts, and checks the status of the 2 bot +components: the event handler and the job manager. It launches both component +processes in the background. + +```bash +./bot.py start [options] +./bot.py stop [options] +./bot.py restart [options] +./bot.py status [options] +``` + +Available options: + +|Option|Argument| +|------|--------| +|`-e` / `--event-handler-opts`|String of options that will be passed to the event manager| +|`-i` / `--instance`|Identifies the bot instance to start, stop, restart, or check (default is `eessi-bot`)| +|`-j` / `--job-manager-opts`|String of options that will be passed to the job manager| + +The following example starts bot instance `test-bot` with 10 job manager +iterations, managing only job ids 1234 and 5678, and listening for events on +port 8080: + +```bash +./bot.py start --instance test-bot --job-manager-opts "-i 10 -j 1234,5678" --event-handler-opts "--port 8080" +``` + # Example pull request on software-layer For information on how to make pull requests and let the bot build software, see diff --git a/bot.py b/bot.py new file mode 100755 index 00000000..5848bc97 --- /dev/null +++ b/bot.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +# +# This file is part of the EESSI build-and-deploy bot, +# see https://github.com/EESSI/eessi-bot-software-layer +# +# The bot helps with requests to add software installations to the +# EESSI software layer, see https://github.com/EESSI/software-layer +# +# author: Samuel Moors (@smoors) +# +# license: GPLv2 +# + +import argparse +import os +import shlex +import signal +import subprocess +import time +from pathlib import Path + +RED = "\033[31m" +BLUE = "\033[34m" +GREEN = "\033[32m" +RESET = "\033[0m" + +JOB_MANAGER_MODULE = "eessi_bot_job_manager" +EVENT_HANDLER_MODULE = "eessi_bot_event_handler" +DEFAULT_INSTANCE = "eessi-bot" + + +def parse_args(): + parser = argparse.ArgumentParser( + usage="%(prog)s {start|stop|restart|status} [options]" + ) + parser.add_argument("command", choices=("start", "stop", "restart", "status")) + parser.add_argument( + "-i", "--instance", + default=DEFAULT_INSTANCE, + help=f"bot instance (default: {DEFAULT_INSTANCE})", + ) + parser.add_argument( + "-j", "--job-manager-opts", + default="", + help="additional job manager options as a string", + ) + parser.add_argument( + "-e", "--event-handler-opts", + default="", + help="additional event handler options as a string", + ) + return parser.parse_args() + + +def get_cmdline(pid): + try: + data = Path(f"/proc/{pid}/cmdline").read_bytes() + except (FileNotFoundError, PermissionError, ProcessLookupError): + return [] + return [arg.decode(errors="replace") for arg in data.split(b"\0") if arg] + + +def find_processes(module, instance): + processes = [] + + for proc in Path("/proc").iterdir(): + if not proc.name.isdigit(): + continue + + pid = int(proc.name) + if pid == os.getpid(): + continue + + cmdline = get_cmdline(pid) + if not cmdline: + continue + + try: + module_index = cmdline.index("-m") + except ValueError: + continue + + if module_index + 1 >= len(cmdline) or cmdline[module_index + 1] != module: + continue + + for i, arg in enumerate(cmdline[:-1]): + if arg == "--instance" and cmdline[i + 1] == instance: + processes.append(pid) + break + + return processes + + +def start_process(module, instance, opts): + cmd = ["python3", "-m", module, "--instance", instance] + if opts: + cmd.extend(shlex.split(opts)) + + env = os.environ.copy() + env["PYTHONUNBUFFERED"] = "1" + + subprocess.Popen(cmd, env=env, start_new_session=True) + + +def start_bot(instance, job_manager_opts, event_handler_opts): + if find_processes(JOB_MANAGER_MODULE, instance): + print(f"{BLUE}>>> job manager for bot instance '{instance}' is already running{RESET}") + else: + print(f"{BLUE}>>> starting job manager for bot instance '{instance}'...{RESET}") + start_process(JOB_MANAGER_MODULE, instance, job_manager_opts) + + if find_processes(EVENT_HANDLER_MODULE, instance): + print(f"{GREEN}>>> event handler for bot instance '{instance}' is already running{RESET}") + else: + print(f"{GREEN}>>> starting event handler for bot instance '{instance}'...{RESET}") + start_process(EVENT_HANDLER_MODULE, instance, event_handler_opts) + + +def stop_processes(module, instance): + for pid in find_processes(module, instance): + try: + cmdline = get_cmdline(pid) + os.kill(pid, signal.SIGTERM) + print(f"killed (pid {pid}): {shlex.join(cmdline)}") + except ProcessLookupError: + pass + + +def stop_bot(instance): + if find_processes(EVENT_HANDLER_MODULE, instance): + print(f"{RED}>>> stopping event handler for bot instance '{instance}'...{RESET}") + stop_processes(EVENT_HANDLER_MODULE, instance) + else: + print(f"{RED}>>> event handler for bot instance '{instance}' is not running{RESET}") + + if find_processes(JOB_MANAGER_MODULE, instance): + print(f"{RED}>>> stopping job manager for bot instance '{instance}'...{RESET}") + stop_processes(JOB_MANAGER_MODULE, instance) + else: + print(f"{RED}>>> job manager for bot instance '{instance}' is not running{RESET}") + + +def status_bot(instance): + if find_processes(JOB_MANAGER_MODULE, instance): + print(f"{BLUE}>>> job manager for bot instance '{instance}' is running{RESET}") + else: + print(f"{RED}>>> job manager for bot instance '{instance}' is not running{RESET}") + + if find_processes(EVENT_HANDLER_MODULE, instance): + print(f"{GREEN}>>> event handler for bot instance '{instance}' is running{RESET}") + else: + print(f"{RED}>>> event handler for bot instance '{instance}' is not running{RESET}") + + +def main(): + args = parse_args() + + if args.command == "start": + start_bot(args.instance, args.job_manager_opts, args.event_handler_opts) + time.sleep(2) + status_bot(args.instance) + elif args.command == "stop": + stop_bot(args.instance) + elif args.command == "restart": + stop_bot(args.instance) + time.sleep(1) + start_bot(args.instance, args.job_manager_opts, args.event_handler_opts) + time.sleep(2) + status_bot(args.instance) + elif args.command == "status": + status_bot(args.instance) + + +if __name__ == "__main__": + main() diff --git a/tests/test_app.cfg b/tests/test_app.cfg index e64e7ffb..c5265faa 100644 --- a/tests/test_app.cfg +++ b/tests/test_app.cfg @@ -17,6 +17,7 @@ hosting_platform = github [github] app_name = test-app-github +api_timeout = 10 [gitlab] api_timeout = 123 @@ -50,6 +51,3 @@ running_job = job `{job_id}` is running [bot_control] command_permission = user01 second_user - -[github] -api_timeout = 10 diff --git a/tests/test_tools_args.py b/tests/test_tools_args.py index a0db511c..39f649ed 100644 --- a/tests/test_tools_args.py +++ b/tests/test_tools_args.py @@ -44,15 +44,17 @@ def test_parse_common_args(test_args, expected_parsed, expected_unknown): # Test event_handler_parse() @pytest.mark.parametrize("test_args,expectation", [ # No args - ([], nullcontext(Namespace(debug=False, build=False, test=False, cron=False, file=None, port=3000))), + ([], nullcontext(Namespace(debug=False, build=False, test=False, cron=False, file=None, port=3000, instance=None))), # Short-form args (["-d", "-b", "-t", "-c", "-f", "file.json", "-p", "8000"], - nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file.json", port="8000"))), + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file.json", port="8000", + instance=None))), # Long-form args - (["--debug", "--build", "--test", "--cron", "--file", "file2.json", "--port", "9000"], - nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file2.json", port="9000"))), + (["--debug", "--build", "--test", "--cron", "--file", "file2.json", "--port", "9000", "--instance", "test-bot"], + nullcontext(Namespace(debug=True, build=True, test=True, cron=True, file="file2.json", port="9000", + instance="test-bot"))), # Unknown args - should fail and exit (["-u"], pytest.raises(SystemExit)), @@ -66,15 +68,15 @@ def test_event_handler_parse_known_args(test_args, expectation): # Test job_manager_parse() @pytest.mark.parametrize("test_args,expectation", [ # No args - ([], nullcontext(Namespace(debug=False, max_manager_iterations=-1, jobs=None))), + ([], nullcontext(Namespace(debug=False, max_manager_iterations=-1, jobs=None, instance=None))), # Short-form args (["-d", "-i", "0", "-j", "17"], - nullcontext(Namespace(debug=True, max_manager_iterations="0", jobs="17"))), + nullcontext(Namespace(debug=True, max_manager_iterations="0", jobs="17", instance=None))), # Long-form args - (["--debug", "--max-manager-iterations", "10", "--jobs", "4,18,48"], - nullcontext(Namespace(debug=True, max_manager_iterations="10", jobs="4,18,48"))), + (["--debug", "--max-manager-iterations", "10", "--jobs", "4,18,48", "--instance", "test-bot"], + nullcontext(Namespace(debug=True, max_manager_iterations="10", jobs="4,18,48", instance="test-bot"))), # Unknown args - should fail and exit (["-u"], pytest.raises(SystemExit)), diff --git a/tools/args.py b/tools/args.py index 27b62ab5..f3739f28 100644 --- a/tools/args.py +++ b/tools/args.py @@ -85,6 +85,12 @@ def event_handler_parse(args=None): help="listen on a specific port for events (default 3000)", ) + parser.add_argument( + "--instance", + help="bot instance (not used by the event handler itself), useful for keeping track of multiple instances on " + "the same machine", + ) + return parser.parse_args(args=unknown_args, namespace=parsed_args) @@ -110,4 +116,10 @@ def job_manager_parse(args=None): help="limits the processing to a specific job id or list of comma-separated list of job ids", ) + parser.add_argument( + "--instance", + help="bot instance (not used by the job manager itself), useful for keeping track of multiple instances on " + "the same machine", + ) + return parser.parse_args(args=unknown_args, namespace=parsed_args)