commit all the files

This commit is contained in:
Michael Pilosov 2022-12-23 21:26:17 -07:00
parent 986a7af5fd
commit 2b21f69005
10 changed files with 536 additions and 1 deletions

33
Dockerfile Normal file
View File

@ -0,0 +1,33 @@
FROM debian:bullseye-slim
WORKDIR /opt/program
# Set some environment variables. PYTHONUNBUFFERED keeps Python from buffering our standard
# output stream, which means that logs can be delivered to the user quickly. PYTHONDONTWRITEBYTECODE
# keeps Python from writing the .pyc files which are unnecessary in this case. We also update
# PATH so that the serve program is found when the container is invoked.
ENV PYTHONUNBUFFERED=TRUE
ENV PYTHONDONTWRITEBYTECODE=TRUE
ENV PATH="/opt/program:${PATH}"
# Get basic dependencies
RUN apt-get -y update && apt-get install -y --no-install-recommends \
build-essential \
python3-pip \
python3-dev \
nginx \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# overwrite the link to system python and upgrade pip
RUN ln -s /usr/bin/python3 /usr/bin/python \
&& pip3 install --upgrade pip
COPY requirements.txt /tmp/
RUN pip3 install -r /tmp/requirements.txt && \
rm -rf /root/.cache
# Set up the program in the image
COPY ./ /opt/program
RUN chmod +x /opt/program/serve.py
EXPOSE 9992
CMD ./serve.py

View File

@ -1,3 +1,20 @@
# mc-status-page
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus.
Logs minecraft status to postgres and creates webpage based on current server status using mcstatus.
## Usage
Create `.env` with these keys:
```
DB_HOST=
DB_NAME=
DB_USER=
DB_PASS=
DB_PROJ=
DB_TABLE=
MINE_IP=
```
where `MINE_IP` is the IP Address of your Minecraft server (including port), and the rest correspond to your PostgreSQL database.
Then `docker-compose up -d` will run the site on port 9992.

95
app.py Normal file
View File

@ -0,0 +1,95 @@
"""Flask app to display a table with the result of a postgreSQL query"""
from flask import Flask, render_template
import pandas as pd
from dotenv import load_dotenv
import os
import psycopg2
import logging
import sys
import datetime
import warnings
warnings.filterwarnings("ignore")
load_dotenv()
app = Flask(__name__)
def connect_to_database(args):
try:
conn = psycopg2.connect(
host=args.dbhost,
database=args.dbname,
user=args.dbuser,
password=args.dbpass,
sslmode='require',
options=f'project={args.project}'
)
cur = conn.cursor()
except Exception as e:
logging.error('Failed to connect to database: %s', e)
sys.exit(1)
table_name = vars(args).get('table', 'minecraft')
# if args.clean doesn't exist, it will be False
if vars(args).get('clean', False):
# remove table if it exists
try:
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
conn.commit()
except Exception as e:
logging.error('Failed to drop table: %s', e)
sys.exit(1)
# Create table if it doesn't exist
try:
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
conn.commit()
except Exception as e:
logging.error('Failed to create table: %s', e)
sys.exit(1)
return conn
@app.route("/")
def index():
# make a fake args object that acts the same way as argparse.Namespace
class Args:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
args = Args(
dbhost=os.environ.get("DB_HOST", "localhost"),
dbname=os.environ.get("DB_NAME", "minecraft"),
dbuser=os.environ.get("DB_USER", "postgres"),
dbpass=os.environ.get("DB_PASS", ""),
project=os.environ.get("DB_PROJ", "default"),
table=os.environ.get("DB_TABLE", "minecraft"),
)
conn = connect_to_database(args)
table_name = vars(args).get("table", "minecraft")
df = pd.read_sql(
f"SELECT * FROM {table_name} ORDER BY timestamp DESC LIMIT 100;", conn
)
online = df["players"].tolist()[0]
df = df[['timestamp', 'players', 'latency']]
# format timestamp
df['timestamp'] = df['timestamp'].dt.strftime('%Y-%m-%d %H:%M:%S')
# if the timestamp is more than 5 minutes old, the server is offline
latest_timestamp = datetime.datetime.strptime(df['timestamp'].tolist()[0], '%Y-%m-%d %H:%M:%S')
if (datetime.datetime.now() - latest_timestamp) > datetime.timedelta(minutes=5):
status = "The server is currently offline. Please try again later."
else:
if online > 0:
status = f"There are currently <b>{ online }</b> players online."
else:
status = "There are currently no players online."
return render_template("index.html", data=df.to_html(justify="center"), status=status)
if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=9993)

34
docker-compose.yml Normal file
View File

@ -0,0 +1,34 @@
# docker-compose file for serving nginx without SSL
version: '3.7'
services:
nginx:
build: .
container_name: mcstatus-server
restart: always
ports:
- 9992:9992
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./html:/usr/share/nginx/html
- ./logs:/var/log/nginx
environment:
- TZ=America/Denver
env_file:
- .env
status:
build: .
container_name: mcstatus
restart: always
environment:
- TZ=America/Denver
env_file:
- ../.env
command: python mc.py
networks:
default:
external:
name: mcstatus

137
mc.py Normal file
View File

@ -0,0 +1,137 @@
"""
Application to ping Minecraft server and write all data to postgresql database.
"""
import os
import sys
import time
import datetime
import logging
import argparse
from mcstatus import JavaServer as MinecraftServer
import psycopg2
from typing import Tuple, Dict
from dotenv import load_dotenv
# read environment variables from .env file
load_dotenv()
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s %(message)s')
# get DB_HOST, DB_PROJ, DB_USER, DB_PASS, and DB_NAME from environment variables
DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_NAME = os.environ.get('DB_NAME', 'main')
DB_TABLE = os.environ.get('DB_TABLE', 'minecraft')
DB_PROJ = os.environ.get('DB_PROJ', 'default')
DB_USER = os.environ.get('DB_USER', 'user')
DB_PASS = os.environ.get('DB_PASS', 'password')
IP_ADD = os.environ.get('MINE_IP', 'localhost')
def parse_args() -> argparse.Namespace:
# Set up command line arguments
parser = argparse.ArgumentParser(description='Monitor Minecraft server activity and write it to postgresql database.')
parser.add_argument('--dbhost', default=DB_HOST, help='Postgresql database host')
parser.add_argument('--dbname', default=DB_NAME, help='Postgresql database name')
parser.add_argument('--dbuser', default=DB_USER, help='Postgresql database user')
parser.add_argument('--dbpass', default=DB_PASS, help='Postgresql database password')
parser.add_argument('--project', default=DB_PROJ, help='Project name')
parser.add_argument('--table', default=DB_TABLE, help='Table name')
parser.add_argument('--ip', default=IP_ADD, help='IP Address of Minecraft Server')
parser.add_argument('--interval', default=60, type=int, help='Interval in seconds between measurements')
parser.add_argument('--clean', action='store_true', help='Clean database before starting (default: False)')
args = parser.parse_args()
return args
# Connect to database
def connect_to_database(args):
try:
conn = psycopg2.connect(
host=args.dbhost,
database=args.dbname,
user=args.dbuser,
password=args.dbpass,
sslmode='require',
options=f'project={args.project}'
)
cur = conn.cursor()
except Exception as e:
logging.error('Failed to connect to database: %s', e)
sys.exit(1)
table_name = vars(args).get('table', DB_TABLE)
# if args.clean doesn't exist, it will be False
if vars(args).get('clean', False):
# remove table if it exists
try:
cur.execute(f'DROP TABLE IF EXISTS {table_name}')
conn.commit()
except Exception as e:
logging.error('Failed to drop table: %s', e)
sys.exit(1)
# Create table if it doesn't exist
try:
cur.execute(f'CREATE TABLE IF NOT EXISTS {table_name} (timestamp TIMESTAMP, server_name TEXT, version TEXT, protocol INT, players INT, max_players INT, latency INT)')
conn.commit()
except Exception as e:
logging.error('Failed to create table: %s', e)
sys.exit(1)
return conn
def get_minecraft_server_status(host: str) -> Dict[str, str]:
# Get Minecraft server status
try:
server = MinecraftServer.lookup(host)
status = server.status()
logging.info('Successfully got Minecraft server status')
except Exception as e:
logging.error('Failed to get Minecraft server status: %s', e)
sys.exit(1)
# extract status as dictionary
status = status.__dict__
return status
def main(args, conn=None):
cur = conn.cursor()
try:
# Get current time
timestamp = datetime.datetime.now()
# Get Minecraft server status
ip_add = vars(args).get('ip', 'localhost:25571')
status = get_minecraft_server_status(ip_add)
table_name = vars(args).get('table', DB_TABLE)
if status:
# Write to postgres the status
cur.execute(f'INSERT INTO {table_name} (timestamp, server_name, version, protocol, players, max_players, latency) VALUES (%s, %s, %s, %s, %s, %s, %s)', (timestamp, status['raw']['description'], status['raw']['version']['name'], status['raw']['version']['protocol'], status['raw']['players']['online'], status['raw']['players']['max'], status['latency']))
conn.commit()
# Sleep for interval
time.sleep(args.interval)
except KeyboardInterrupt:
logging.info('Exiting...')
sys.exit(0)
except Exception as e:
logging.error('Failed to write to database: %s', e)
# sys.exit(1)
if __name__ == '__main__':
# Main loop
args = parse_args()
conn = connect_to_database(args)
while True:
# if connection to server is lost, reconnect
if not conn:
logging.info('Reconnecting to database...')
conn = connect_to_database(args)
main(args, conn=conn)

73
nginx.conf Normal file
View File

@ -0,0 +1,73 @@
worker_processes 1;
daemon off; # Prevent forking
pid /tmp/nginx.pid;
error_log /var/log/nginx/error.log;
events {
# defaults
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log combined;
upstream gunicorn {
server unix:/tmp/gunicorn.sock;
}
server {
listen 9992 deferred;
client_max_body_size 5m;
keepalive_timeout 5;
proxy_read_timeout 1200s;
location ~ ^/ {
#
# Wide-open CORS config for nginx
#
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
#
# Om nom nom cookies
#
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
#
# Custom headers and headers various browsers *should* be OK with but aren't
#
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
#
# Tell client that this pre-flight info is valid for 20 days
#
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
if ($request_method = 'POST') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
if ($request_method = 'GET') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type';
}
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://gunicorn;
}
location / {
return 404 "{}";
}
}
}

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
gunicorn
gevent
flask
mcstatus
psycopg2-binary
pandas
python-dotenv

96
serve.py Executable file
View File

@ -0,0 +1,96 @@
#!/usr/bin/env python
# This file implements the API shell.
# You don't necessarily need to modify it for various algorithms.
# It starts nginx and gunicorn with the correct configurations and
# then waits until gunicorn exits.
#
# The flask server is specified to be the app object in wsgi.py
#
# We set the following parameters:
#
# Parameter Environment Variable Default Value
# --------- -------------------- -------------
# number of workers MODEL_SERVER_WORKERS the number of CPU cores
# timeout MODEL_SERVER_TIMEOUT 60 seconds
from __future__ import print_function
import multiprocessing
import os
import signal
import subprocess
import sys
cpu_count = multiprocessing.cpu_count()
model_server_timeout = os.environ.get("MODEL_SERVER_TIMEOUT", 60)
model_server_workers = int(os.environ.get("MODEL_SERVER_WORKERS", cpu_count))
def sigterm_handler(nginx_pid, gunicorn_pid):
try:
os.kill(nginx_pid, signal.SIGQUIT)
except OSError:
pass
try:
os.kill(gunicorn_pid, signal.SIGTERM)
except OSError:
pass
sys.exit(0)
def start_server():
print(f"Starting server with {model_server_workers} workers.")
# link the log streams to stdout/err so that
# they will be logged to the container logs
subprocess.check_call(["ln", "-sf", "/dev/stdout", "/var/log/nginx/access.log"])
subprocess.check_call(["ln", "-sf", "/dev/stderr", "/var/log/nginx/error.log"])
nginx = subprocess.Popen(["nginx", "-c", "/opt/program/nginx.conf"])
gunicorn = subprocess.Popen(
[
"gunicorn",
"--timeout",
str(model_server_timeout),
"-k",
"gevent",
"-b",
"unix:/tmp/gunicorn.sock",
"-w",
str(model_server_workers),
"wsgi:app",
]
)
# NOTE: https://docs.python.org/3/library/signal.html#signal.signal
# This sets a handler (second argument) for the signal SIGTERM (first argument)
# in the function `signal.signal` which we want to call (unclear why...)
# The handler should be a callable which takes two arguments (hence `a`, `b`),
# and it is called with two arguments: the signal number and current stack frame.
# Neither of these are relevant to us, so we define a dummy handler which kills
# the two processes we have started (nginx and gunicorn) regardless of `a`/`b`.
# Basically, we are hard-coding "kill these two processes if you get a SIGTERM"
def handler(a, b, c):
return sigterm_handler(nginx.pid, gunicorn.pid)
signal.signal(signal.SIGTERM, handler)
# If either subprocess exits, so do we.
pids = set([nginx.pid, gunicorn.pid])
while True:
pid, _ = os.wait()
if pid in pids:
break
sigterm_handler(nginx.pid, gunicorn.pid)
print("API server exiting")
# The main routine just invokes the start function.
if __name__ == "__main__":
start_server()

36
templates/index.html Normal file
View File

@ -0,0 +1,36 @@
<!-- Flask template for displaying the results of a SQL query as a basic website -->
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Minecraft Server Status</title>
</head>
<body>
<h1>Minecraft Server Status</h1>
<div align="center">
<p style="font-size: 150%;">
{{ status|safe }}
</p>
</div>
<br>
<!-- inline style to center table elements and create spacing in each column-->
<style>
table, th, td {
border: 1px solid black;
border-collapse: collapse;
text-align: center;
padding: 5px;
font-size: 125%;
}
</style>
<div align="center">
{{ data|safe }}
</div>
</body>
</html>

7
wsgi.py Normal file
View File

@ -0,0 +1,7 @@
import app as myapp
# This is just a simple wrapper for gunicorn to find your app.
# If you want to change the algorithm file, simply change "main"
# above to the new file.
app = myapp.app