diff --git a/.env.example b/.env.example index 5ecf51a..2c2189d 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,7 @@ DISCORD_TOKEN= # Optional tuning CHECK_INTERVAL=60 CHECK_TIMEOUT=10 + +# Targets file (hot-loaded each scan) and its auto-maintained last-good copy +# TARGETS_FILE=targets.json +# TARGETS_BACKUP_FILE=targets.json.bak diff --git a/.gitignore b/.gitignore index 70a6c06..7595791 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ venv/ __pycache__/ *.pyc .status_message.json +targets.json.bak diff --git a/checks.py b/checks.py index 7d1cadd..5e2de29 100644 --- a/checks.py +++ b/checks.py @@ -1,32 +1,16 @@ import asyncio import json +import logging +import shutil import time from urllib.parse import urlparse import aiohttp from mcstatus import JavaServer -TARGETS = [ - { - 'name': 'Gitea', - 'url': 'https://gitea.rainbow-herring.ts.net/api/healthz', - 'kind': 'gitea', - 'critical': True, - }, - { - 'name': 'Packs Site (Production)', - 'url': 'https://jakerasp.rainbow-herring.ts.net/healthz/', - 'kind': 'packs', - 'critical': True, - }, - { - 'name': 'Minecraft Server', - 'host': 'jakelaptop.rainbow-herring.ts.net', - 'port': 10000, - 'kind': 'minecraft', - 'critical': True, - }, -] +import config + +log = logging.getLogger('healthbot') GITEA_VERSION_URL = 'https://gitea.rainbow-herring.ts.net/api/v1/version' @@ -36,6 +20,75 @@ DOH_RESOLVERS = { } +class TargetsConfigError(Exception): + """Raised when the targets configuration file is missing or invalid.""" + + +def validate_targets(data): + """Validate parsed targets data, returning a normalized list of targets.""" + if not isinstance(data, list): + raise TargetsConfigError('targets must be a JSON list') + targets = [] + for i, entry in enumerate(data): + if not isinstance(entry, dict): + raise TargetsConfigError(f'entry {i} is not an object') + name = entry.get('name') + if not isinstance(name, str) or not name.strip(): + raise TargetsConfigError(f'entry {i} missing a valid "name"') + kind = entry.get('kind') + if not isinstance(kind, str) or not kind.strip(): + raise TargetsConfigError(f'entry {i} missing a valid "kind"') + if kind == 'minecraft': + host = entry.get('host') + port = entry.get('port') + if not isinstance(host, str) or not host.strip(): + raise TargetsConfigError(f'entry {i} missing a valid "host"') + if not isinstance(port, int) or not (0 < port < 65536): + raise TargetsConfigError(f'entry {i} missing a valid "port"') + else: + url = entry.get('url') + if not isinstance(url, str) or not url.strip(): + raise TargetsConfigError(f'entry {i} missing a valid "url"') + critical = entry.get('critical', True) + if not isinstance(critical, bool): + raise TargetsConfigError(f'entry {i} "critical" must be true/false') + target = {'name': name.strip(), 'kind': kind.strip(), 'critical': critical} + target.update({k: entry[k] for k in ('url', 'host', 'port') if k in entry}) + targets.append(target) + return targets + + +def load_targets(): + """Load the live targets file; on success refresh the backup copy. + + Raises TargetsConfigError if the live file is missing or invalid. + """ + try: + with open(config.TARGETS_FILE, 'r') as f: + raw = f.read() + targets = validate_targets(json.loads(raw)) + except (OSError, ValueError, TargetsConfigError) as exc: + raise TargetsConfigError( + f'invalid targets file {config.TARGETS_FILE}: {exc}' + ) from exc + try: + shutil.copyfile(config.TARGETS_FILE, config.TARGETS_BACKUP_FILE) + except OSError as exc: + log.warning('Could not back up targets file: %s', exc) + return targets + + +def load_backup_targets(): + """Load the last known-good copy of the targets file.""" + try: + with open(config.TARGETS_BACKUP_FILE, 'r') as f: + return validate_targets(json.load(f)) + except (OSError, ValueError, TargetsConfigError) as exc: + raise TargetsConfigError( + f'backup targets file {config.TARGETS_BACKUP_FILE} also invalid: {exc}' + ) from exc + + async def _get(session, url, timeout): started = time.monotonic() async with session.get( @@ -179,7 +232,14 @@ async def check_target(session, target, timeout): async def run_checks(session, timeout): + using_backup = False + try: + targets = load_targets() + except TargetsConfigError as exc: + log.exception('Targets config problem: %s', exc) + targets = load_backup_targets() + using_backup = True results = await asyncio.gather( - *(check_target(session, target, timeout) for target in TARGETS) + *(check_target(session, target, timeout) for target in targets) ) - return list(results) + return list(results), using_backup diff --git a/config.py b/config.py index 01688a9..15ea247 100644 --- a/config.py +++ b/config.py @@ -10,3 +10,6 @@ CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '60')) CHECK_TIMEOUT = float(os.getenv('CHECK_TIMEOUT', '10')) STATE_FILE = os.getenv('STATE_FILE', '.status_message.json') + +TARGETS_FILE = os.getenv('TARGETS_FILE', 'targets.json') +TARGETS_BACKUP_FILE = os.getenv('TARGETS_BACKUP_FILE', 'targets.json.bak') diff --git a/main.py b/main.py index ee2acfe..ec9aa10 100644 --- a/main.py +++ b/main.py @@ -77,7 +77,7 @@ class HealthBot(discord.Client): session = await self._ensure_session() return await checks.run_checks(session, config.CHECK_TIMEOUT) - def build_embed(self, results): + def build_embed(self, results, using_backup=False): color = _health_color(results) embed = discord.Embed( title='Service Health', @@ -115,14 +115,20 @@ class HealthBot(discord.Client): lines.append(f'**DNS resolution ({label})** {val}') embed.add_field(name=r['name'], value='\n'.join(lines), inline=False) - embed.set_footer(text=f'Updates every {config.CHECK_INTERVAL}s') + embed.set_footer(text=self._footer_text(using_backup)) return embed + def _footer_text(self, using_backup): + text = f'Updates every {config.CHECK_INTERVAL}s' + if using_backup: + text += ' · ⚠ targets.json invalid — using last known-good config' + return text + async def update_embeds(self): if not self.status_messages: return - results = await self.run_checks() - embed = self.build_embed(results) + results, using_backup = await self.run_checks() + embed = self.build_embed(results, using_backup) kept = [] for entry in self.status_messages: @@ -172,8 +178,14 @@ bot = HealthBot() @bot.tree.command(name='status', description='Post a live-updating health embed for the services') async def status(interaction: discord.Interaction): await interaction.response.defer() - results = await bot.run_checks() - embed = bot.build_embed(results) + try: + results, using_backup = await bot.run_checks() + except checks.TargetsConfigError as exc: + await interaction.followup.send( + f'Could not load targets config: {exc}', ephemeral=True, + ) + return + embed = bot.build_embed(results, using_backup) existing = next( (m for m in bot.status_messages if m['channel_id'] == interaction.channel_id), diff --git a/targets.json b/targets.json new file mode 100644 index 0000000..31fe542 --- /dev/null +++ b/targets.json @@ -0,0 +1,21 @@ +[ + { + "name": "Gitea", + "url": "https://gitea.rainbow-herring.ts.net/api/healthz", + "kind": "gitea", + "critical": true + }, + { + "name": "Packs Site (Production)", + "url": "https://jakerasp.rainbow-herring.ts.net/healthz/", + "kind": "packs", + "critical": true + }, + { + "name": "Minecraft Server", + "host": "jakelaptop.rainbow-herring.ts.net", + "port": 10000, + "kind": "minecraft", + "critical": true + } +] \ No newline at end of file