Pulling master over to main #3
@ -19,6 +19,14 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
run: |
|
||||
@ -34,30 +42,35 @@ jobs:
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
push: ${{ github.ref == 'refs/heads/main' }}
|
||||
tags: |
|
||||
paper-dynasty:latest
|
||||
paper-dynasty:v${{ steps.meta.outputs.version }}
|
||||
paper-dynasty:${{ steps.meta.outputs.version_sha }}
|
||||
manticorum67/paper-dynasty:latest
|
||||
manticorum67/paper-dynasty:v${{ steps.meta.outputs.version }}
|
||||
manticorum67/paper-dynasty:${{ steps.meta.outputs.version_sha }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
outputs: type=docker,dest=/tmp/paper-dynasty-image.tar
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
echo "## 🐳 Docker Build Successful! ✅" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Image Tags:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`paper-dynasty:v${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`paper-dynasty:${{ steps.meta.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty:v${{ steps.meta.outputs.version }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- \`manticorum67/paper-dynasty:${{ steps.meta.outputs.version_sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Build Details:**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Branch: \`${{ steps.meta.outputs.branch }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Commit: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Timestamp: \`${{ steps.meta.outputs.timestamp }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "_Ready to deploy to production!_" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "🚀 **Pushed to Docker Hub!**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Pull with: \`docker pull manticorum67/paper-dynasty:latest\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "_PR build - image not pushed to Docker Hub_" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
- name: Save build info
|
||||
run: |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -129,3 +129,14 @@ class LegalityCheckNotRequired(GameException):
|
||||
|
||||
class InvalidResponder(GameException):
|
||||
pass
|
||||
|
||||
|
||||
class PlayLockedException(GameException):
|
||||
"""
|
||||
Raised when attempting to process a play that is already locked by another interaction.
|
||||
|
||||
This prevents concurrent modification of the same play record, which could cause
|
||||
database deadlocks or data corruption.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
106
health_server.py
106
health_server.py
@ -3,6 +3,7 @@ HTTP health check server for Paper Dynasty Discord bot.
|
||||
|
||||
Provides health and readiness endpoints for container monitoring and orchestration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
@ -10,13 +11,13 @@ from aiohttp import web
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
logger = logging.getLogger('discord_app.health')
|
||||
logger = logging.getLogger("discord_app.health")
|
||||
|
||||
|
||||
class HealthServer:
|
||||
"""HTTP server for health checks and metrics."""
|
||||
|
||||
def __init__(self, bot: commands.Bot, host: str = '0.0.0.0', port: int = 8080):
|
||||
def __init__(self, bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
|
||||
"""
|
||||
Initialize health server.
|
||||
|
||||
@ -33,9 +34,10 @@ class HealthServer:
|
||||
self.site: Optional[web.TCPSite] = None
|
||||
|
||||
# Setup routes
|
||||
self.app.router.add_get('/health', self.health_check)
|
||||
self.app.router.add_get('/ready', self.readiness_check)
|
||||
self.app.router.add_get('/metrics', self.metrics)
|
||||
self.app.router.add_get("/health", self.health_check)
|
||||
self.app.router.add_get("/ready", self.readiness_check)
|
||||
self.app.router.add_get("/metrics", self.metrics)
|
||||
self.app.router.add_get("/diagnostics", self.diagnostics)
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
@ -43,10 +45,9 @@ class HealthServer:
|
||||
|
||||
Returns 200 if the server is responsive.
|
||||
"""
|
||||
return web.json_response({
|
||||
'status': 'healthy',
|
||||
'service': 'paper-dynasty-discord-bot'
|
||||
})
|
||||
return web.json_response(
|
||||
{"status": "healthy", "service": "paper-dynasty-discord-bot"}
|
||||
)
|
||||
|
||||
async def readiness_check(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
@ -57,16 +58,19 @@ class HealthServer:
|
||||
503 if bot is not ready
|
||||
"""
|
||||
if self.bot.is_ready():
|
||||
return web.json_response({
|
||||
'status': 'ready',
|
||||
'discord_connected': True,
|
||||
'latency_ms': round(self.bot.latency * 1000, 2) if self.bot.latency else None
|
||||
})
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ready",
|
||||
"discord_connected": True,
|
||||
"latency_ms": round(self.bot.latency * 1000, 2)
|
||||
if self.bot.latency
|
||||
else None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
return web.json_response({
|
||||
'status': 'not_ready',
|
||||
'discord_connected': False
|
||||
}, status=503)
|
||||
return web.json_response(
|
||||
{"status": "not_ready", "discord_connected": False}, status=503
|
||||
)
|
||||
|
||||
async def metrics(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
@ -75,33 +79,65 @@ class HealthServer:
|
||||
Provides detailed information about bot state for external monitoring systems.
|
||||
"""
|
||||
metrics_data = {
|
||||
'bot': {
|
||||
'is_ready': self.bot.is_ready(),
|
||||
'is_closed': self.bot.is_closed(),
|
||||
'latency_ms': round(self.bot.latency * 1000, 2) if self.bot.latency else None,
|
||||
"bot": {
|
||||
"is_ready": self.bot.is_ready(),
|
||||
"is_closed": self.bot.is_closed(),
|
||||
"latency_ms": round(self.bot.latency * 1000, 2)
|
||||
if self.bot.latency
|
||||
else None,
|
||||
},
|
||||
'guilds': {
|
||||
'count': len(self.bot.guilds),
|
||||
'guild_ids': [g.id for g in self.bot.guilds]
|
||||
"guilds": {
|
||||
"count": len(self.bot.guilds),
|
||||
"guild_ids": [g.id for g in self.bot.guilds],
|
||||
},
|
||||
'users': {
|
||||
'count': len(self.bot.users)
|
||||
},
|
||||
'cogs': {
|
||||
'loaded': list(self.bot.cogs.keys()),
|
||||
'count': len(self.bot.cogs)
|
||||
}
|
||||
"users": {"count": len(self.bot.users)},
|
||||
"cogs": {"loaded": list(self.bot.cogs.keys()), "count": len(self.bot.cogs)},
|
||||
}
|
||||
|
||||
return web.json_response(metrics_data)
|
||||
|
||||
async def diagnostics(self, request: web.Request) -> web.Response:
|
||||
"""
|
||||
Detailed diagnostics for troubleshooting frozen bot.
|
||||
Captures state before container restart.
|
||||
"""
|
||||
import sys
|
||||
|
||||
tasks_info = []
|
||||
try:
|
||||
for task in asyncio.all_tasks():
|
||||
tasks_info.append(
|
||||
{
|
||||
"name": task.get_name(),
|
||||
"done": task.done(),
|
||||
"cancelled": task.cancelled(),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
tasks_info = [f"Error capturing tasks: {e}"]
|
||||
|
||||
diagnostics_data = {
|
||||
"bot": {
|
||||
"is_ready": self.bot.is_ready(),
|
||||
"is_closed": self.bot.is_closed(),
|
||||
"latency_ms": round(self.bot.latency * 1000, 2)
|
||||
if self.bot.latency
|
||||
else None,
|
||||
},
|
||||
"tasks": {"count": len(tasks_info), "tasks": tasks_info[:20]},
|
||||
"cogs": {"loaded": list(self.bot.cogs.keys()), "count": len(self.bot.cogs)},
|
||||
"python_version": sys.version,
|
||||
}
|
||||
|
||||
return web.json_response(diagnostics_data)
|
||||
|
||||
async def start(self):
|
||||
"""Start the health check server."""
|
||||
self.runner = web.AppRunner(self.app)
|
||||
await self.runner.setup()
|
||||
self.site = web.TCPSite(self.runner, self.host, self.port)
|
||||
await self.site.start()
|
||||
logger.info(f'Health check server started on {self.host}:{self.port}')
|
||||
logger.info(f"Health check server started on {self.host}:{self.port}")
|
||||
|
||||
async def stop(self):
|
||||
"""Stop the health check server."""
|
||||
@ -109,10 +145,10 @@ class HealthServer:
|
||||
await self.site.stop()
|
||||
if self.runner:
|
||||
await self.runner.cleanup()
|
||||
logger.info('Health check server stopped')
|
||||
logger.info("Health check server stopped")
|
||||
|
||||
|
||||
async def run_health_server(bot: commands.Bot, host: str = '0.0.0.0', port: int = 8080):
|
||||
async def run_health_server(bot: commands.Bot, host: str = "0.0.0.0", port: int = 8080):
|
||||
"""
|
||||
Run health server as a background task.
|
||||
|
||||
|
||||
57
notify_restart.py
Normal file
57
notify_restart.py
Normal file
@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Notify admin channel when bot restarts due to healthcheck failure.
|
||||
Uses Discord webhook for instant notification.
|
||||
"""
|
||||
|
||||
import os
|
||||
import urllib.request
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def send_restart_notification():
|
||||
"""Send notification to Discord via webhook."""
|
||||
webhook_url = os.getenv("RESTART_WEBHOOK_URL")
|
||||
|
||||
if not webhook_url:
|
||||
print("No RESTART_WEBHOOK_URL configured, skipping notification")
|
||||
return False
|
||||
|
||||
try:
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S CST")
|
||||
|
||||
data = {
|
||||
"content": (
|
||||
f"**Paper Dynasty Bot Restarted** at {timestamp}\n\n"
|
||||
"Bot has successfully connected to Discord.\n\n"
|
||||
"Check `/logs/discord.log` for startup details."
|
||||
),
|
||||
"username": "Paper Dynasty Monitor",
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
webhook_url,
|
||||
data=json.dumps(data).encode("utf-8"),
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Paper-Dynasty-Discord-Bot/1.0",
|
||||
},
|
||||
)
|
||||
|
||||
response = urllib.request.urlopen(req, timeout=5)
|
||||
|
||||
if response.status == 204:
|
||||
print(f"Restart notification sent successfully at {timestamp}")
|
||||
return True
|
||||
else:
|
||||
print(f"Webhook returned status {response.status}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to send restart notification: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
send_restart_notification()
|
||||
@ -9,6 +9,7 @@ from discord.ext import commands
|
||||
|
||||
from in_game.gameplay_models import create_db_and_tables
|
||||
from health_server import run_health_server
|
||||
from notify_restart import send_restart_notification
|
||||
|
||||
raw_log_level = os.getenv('LOG_LEVEL')
|
||||
if raw_log_level == 'DEBUG':
|
||||
@ -70,6 +71,9 @@ async def on_ready():
|
||||
logger.info(bot.user.name)
|
||||
logger.info(bot.user.id)
|
||||
|
||||
# Send restart notification if configured
|
||||
send_restart_notification()
|
||||
|
||||
|
||||
@bot.tree.error
|
||||
async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError):
|
||||
|
||||
Loading…
Reference in New Issue
Block a user