From 8468697cd06ebeb5ebdef803ecdad344528f7487 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 15 Dec 2024 16:35:38 -0500 Subject: [PATCH] feat(webapp): monitor degraded status (#376) --- src/common/webapp.py | 17 +++++++++-- src/discord/bot.py | 11 ++++++- src/reddit/bot.py | 8 +++-- tests/unit/common/test_common.py | 50 ++++++++++++++++++++++++++++++++ tests/unit/common/test_webapp.py | 19 +++++++++++- 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 tests/unit/common/test_common.py diff --git a/src/common/webapp.py b/src/common/webapp.py index 921fcbd..65a1935 100644 --- a/src/common/webapp.py +++ b/src/common/webapp.py @@ -12,7 +12,7 @@ from werkzeug.middleware.proxy_fix import ProxyFix # local imports -from src.common.common import app_dir, colors +from src.common.common import app_dir, colors, version from src.common import crypto from src.common import globals from src.common import time @@ -77,7 +77,20 @@ def html_to_md(html: str) -> str: @app.route('/status') def status(): - return "LizardByte-bot is live!" + degraded_checks = [ + getattr(globals.DISCORD_BOT, 'DEGRADED', True), + getattr(globals.REDDIT_BOT, 'DEGRADED', True), + ] + + s = 'ok' + if any(degraded_checks): + s = 'degraded' + + result = { + "status": s, + "version": version, + } + return jsonify(result) @app.route("/favicon.ico") diff --git a/src/discord/bot.py b/src/discord/bot.py index 390558d..8b5cba5 100644 --- a/src/discord/bot.py +++ b/src/discord/bot.py @@ -32,6 +32,8 @@ def __init__(self, *args, **kwargs): kwargs['auto_sync_commands'] = True super().__init__(*args, **kwargs) + self.DEGRADED = False + self.bot_thread = threading.Thread(target=lambda: None) self.token = os.environ['DISCORD_BOT_TOKEN'] self.db = Database(db_path=os.path.join(data_dir, 'discord_bot_database')) @@ -122,7 +124,12 @@ async def async_send_message( embed.description = embed.description[:-cut_length] + "..." channel = await self.fetch_channel(channel_id) - return await channel.send(content=message, embed=embed) + + try: + return await channel.send(content=message, embed=embed) + except Exception as e: + print(f"Error sending message: {e}") + self.DEGRADED = True def send_message( self, @@ -273,10 +280,12 @@ def start_threaded(self): self.bot_thread.start() except KeyboardInterrupt: print("Keyboard Interrupt Detected") + self.DEGRADED = True self.stop() def stop(self, future: asyncio.Future = None): print("Attempting to stop tasks") + self.DEGRADED = True self.daily_task.stop() self.role_update_task.stop() self.clean_ephemeral_cache.stop() diff --git a/src/reddit/bot.py b/src/reddit/bot.py index e0c5755..d8178b4 100644 --- a/src/reddit/bot.py +++ b/src/reddit/bot.py @@ -19,6 +19,7 @@ class Bot: def __init__(self, **kwargs): self.STOP_SIGNAL = False + self.DEGRADED = False # threads self.bot_thread = threading.Thread(target=lambda: None) @@ -57,8 +58,7 @@ def __init__(self, **kwargs): self.migrate_shelve() self.migrate_last_online() - @staticmethod - def validate_env() -> bool: + def validate_env(self) -> bool: required_env = [ 'DISCORD_REDDIT_CHANNEL_ID', 'PRAW_CLIENT_ID', @@ -69,6 +69,7 @@ def validate_env() -> bool: for env in required_env: if env not in os.environ: sys.stderr.write(f"Environment variable ``{env}`` must be defined\n") + self.DEGRADED = True return False return True @@ -165,6 +166,7 @@ def discord(self, submission: models.Submission): try: redditor = self.reddit.redditor(name=submission.author) except Exception: + self.DEGRADED = True return # create the discord embed @@ -266,11 +268,13 @@ def start_threaded(self): self.bot_thread.start() except KeyboardInterrupt: print("Keyboard Interrupt Detected") + self.DEGRADED = True self.stop() def stop(self): print("Attempting to stop reddit bot") self.STOP_SIGNAL = True + self.DEGRADED = True if self.bot_thread is not None and self.bot_thread.is_alive(): self.comment_thread.join() self.submission_thread.join() diff --git a/tests/unit/common/test_common.py b/tests/unit/common/test_common.py new file mode 100644 index 0000000..a87b03c --- /dev/null +++ b/tests/unit/common/test_common.py @@ -0,0 +1,50 @@ +# standard imports +import os +import re + +# lib imports +import pytest + +# local imports +from src.common import common + + +@pytest.fixture(scope='module') +def github_email(): + return 'octocat@github.com' + + +def test_colors(): + for color in common.colors.values(): + assert 0x000000 <= color <= 0xFFFFFF, f"{color} is not a valid hex color" + + +def test_get_bot_avatar(github_email): + url = common.get_bot_avatar(gravatar=github_email) + print(url) + assert url.startswith('https://www.gravatar.com/avatar/') + + +def test_get_avatar_bytes(github_email, mocker): + mocker.patch('src.common.common.avatar', common.get_bot_avatar(gravatar=github_email)) + avatar_bytes = common.get_avatar_bytes() + assert avatar_bytes + assert isinstance(avatar_bytes, bytes) + + +def test_get_app_dirs(): + app_dir, data_dir = common.get_app_dirs() + assert app_dir + assert data_dir + assert os.path.exists(app_dir) + assert os.path.exists(data_dir) + assert os.path.isdir(app_dir) + assert os.path.isdir(data_dir) + assert app_dir == (os.getcwd() or '/app') + assert data_dir == (os.path.join(os.getcwd(), 'data') or '/data') + + +def test_version(): + assert common.version + assert isinstance(common.version, str) + assert re.match(r'^\d+\.\d+\.\d+$', common.version) diff --git a/tests/unit/common/test_webapp.py b/tests/unit/common/test_webapp.py index ee2fce9..3e781a1 100644 --- a/tests/unit/common/test_webapp.py +++ b/tests/unit/common/test_webapp.py @@ -24,14 +24,31 @@ def test_client(): yield test_client # this is where the testing happens! -def test_status(test_client): +@pytest.mark.parametrize("degraded", [ + False, + True, +]) +def test_status(test_client, discord_bot, degraded, mocker): """ WHEN the '/status' page is requested (GET) THEN check that the response is valid """ + # patch reddit bot, since we're not using its fixture + mocker.patch('src.common.globals.REDDIT_BOT', Mock(DEGRADED=False)) + + if degraded: + mocker.patch('src.common.globals.DISCORD_BOT.DEGRADED', True) + response = test_client.get('/status') assert response.status_code == 200 + if not degraded: + assert response.json['status'] == 'ok' + else: + assert response.json['status'] == 'degraded' + + assert response.json['version'] + def test_favicon(test_client): """