Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

## <a name="step7.3"></a>Step 7.3: Managing the bot with a single command
Comment thread
smoors marked this conversation as resolved.

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
Expand Down
175 changes: 175 additions & 0 deletions bot.py
Original file line number Diff line number Diff line change
@@ -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()
4 changes: 1 addition & 3 deletions tests/test_app.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ hosting_platform = github

[github]
app_name = test-app-github
api_timeout = 10

[gitlab]
api_timeout = 123
Expand Down Expand Up @@ -50,6 +51,3 @@ running_job = job `{job_id}` is running

[bot_control]
command_permission = user01 second_user

[github]
api_timeout = 10
18 changes: 10 additions & 8 deletions tests/test_tools_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
Expand All @@ -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)),
Expand Down
12 changes: 12 additions & 0 deletions tools/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Expand All @@ -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)
Loading