Made targets work with a json

This commit is contained in:
2026-08-22 10:17:23 -05:00
parent 548fdfebaa
commit 363b0b9998
6 changed files with 130 additions and 29 deletions
+4
View File
@@ -4,3 +4,7 @@ DISCORD_TOKEN=
# Optional tuning # Optional tuning
CHECK_INTERVAL=60 CHECK_INTERVAL=60
CHECK_TIMEOUT=10 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
+1
View File
@@ -3,3 +3,4 @@ venv/
__pycache__/ __pycache__/
*.pyc *.pyc
.status_message.json .status_message.json
targets.json.bak
+83 -23
View File
@@ -1,32 +1,16 @@
import asyncio import asyncio
import json import json
import logging
import shutil
import time import time
from urllib.parse import urlparse from urllib.parse import urlparse
import aiohttp import aiohttp
from mcstatus import JavaServer from mcstatus import JavaServer
TARGETS = [ import config
{
'name': 'Gitea', log = logging.getLogger('healthbot')
'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,
},
]
GITEA_VERSION_URL = 'https://gitea.rainbow-herring.ts.net/api/v1/version' 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): async def _get(session, url, timeout):
started = time.monotonic() started = time.monotonic()
async with session.get( async with session.get(
@@ -179,7 +232,14 @@ async def check_target(session, target, timeout):
async def run_checks(session, 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( 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
+3
View File
@@ -10,3 +10,6 @@ CHECK_INTERVAL = int(os.getenv('CHECK_INTERVAL', '60'))
CHECK_TIMEOUT = float(os.getenv('CHECK_TIMEOUT', '10')) CHECK_TIMEOUT = float(os.getenv('CHECK_TIMEOUT', '10'))
STATE_FILE = os.getenv('STATE_FILE', '.status_message.json') 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')
+18 -6
View File
@@ -77,7 +77,7 @@ class HealthBot(discord.Client):
session = await self._ensure_session() session = await self._ensure_session()
return await checks.run_checks(session, config.CHECK_TIMEOUT) 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) color = _health_color(results)
embed = discord.Embed( embed = discord.Embed(
title='Service Health', title='Service Health',
@@ -115,14 +115,20 @@ class HealthBot(discord.Client):
lines.append(f'**DNS resolution ({label})** {val}') lines.append(f'**DNS resolution ({label})** {val}')
embed.add_field(name=r['name'], value='\n'.join(lines), inline=False) 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 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): async def update_embeds(self):
if not self.status_messages: if not self.status_messages:
return return
results = await self.run_checks() results, using_backup = await self.run_checks()
embed = self.build_embed(results) embed = self.build_embed(results, using_backup)
kept = [] kept = []
for entry in self.status_messages: 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') @bot.tree.command(name='status', description='Post a live-updating health embed for the services')
async def status(interaction: discord.Interaction): async def status(interaction: discord.Interaction):
await interaction.response.defer() await interaction.response.defer()
results = await bot.run_checks() try:
embed = bot.build_embed(results) 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( existing = next(
(m for m in bot.status_messages if m['channel_id'] == interaction.channel_id), (m for m in bot.status_messages if m['channel_id'] == interaction.channel_id),
+21
View File
@@ -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
}
]