API Server initial commit

This commit is contained in:
harmacist 2023-02-11 20:32:02 -06:00
parent bce7314e43
commit 8e09c58ac5
2 changed files with 144 additions and 236 deletions

View File

@ -1,251 +1,143 @@
# find minecraft installation
# install forge client
# create install if one doesn't already exist
# copy mods to folder
import os
from sys import stdout
import json
import uuid
import logging
import webbrowser
from fastapi import FastAPI, status
from pydantic import BaseModel, Field
from pathlib import Path
from datetime import datetime
from distutils.dir_util import copy_tree
import zipfile
# from StickyPiston import StickyPiston
from datetime import datetime, timedelta
import docker
app = FastAPI()
client = docker.from_env()
DT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
PROFILE_NAME = "William's Modded 1.19.2"
class PyPistonServer(BaseModel):
server_id: str | None = None
server_name: str | None = None
container_name: str
image: str = Field(
title="The base docker image that was used to create this server"
)
version: str = Field(
title="The version of the docker image that was used to create this server"
)
description: str | None = Field(
default=None,
title="Description or notes for the server instance",
max_length=300
)
motd: str | None = Field(
default=None,
title="Message of the Day that will appear on the Minecraft server listing page in the minecraft client",
max_length=300
)
rcon_enabled: bool = Field(
default=True,
title="Whether or not this container can be managed via rcon"
)
ports: dict = Field(
default={25565: 25565},
title="List of ports that have been published to the host"
)
is_vanilla: bool = Field(
default=False,
title="Whether or not the server is running a modded vanilla (non-modded/custom) of Minecraft"
)
snooper_enabled: bool = True
status: str | None = Field(
default=None,
title="State of the docker container"
)
uptime: str | None = Field(
default=None,
title="Time the container has been up"
)
world_name: str = "world"
online_mode: bool = True
allow_flight: bool = False
class MinecraftProfile:
def __init__(
self,
profile_id: str,
dt_created: datetime,
icon: str,
last_used: datetime,
last_version_id: str,
profile_name: str = '',
profile_type: str = 'custom',
java_args: str = '',
game_dir: Path = None
):
self.profile_id = profile_id
self.dt_created = dt_created
self.icon = icon
self.last_used = last_used
self.last_version_id = last_version_id
self.profile_name = profile_name
self.profile_type = profile_type
self.java_args = java_args
self.game_dir = game_dir
def get_env_value(env_key: str, env_list: list):
val = [e for e in env_list if f"{env_key}=" in e]
return val[0].split('=')[-1] if val else None
@classmethod
def from_json_obj(cls, obj, profile_id):
return cls(
profile_id=profile_id,
dt_created=datetime.strptime(obj['created'], DT_FORMAT) if obj.get('created') else None,
icon=obj.get('icon', 'Crafting_Table'),
last_used=datetime.strptime(obj['lastUsed'], DT_FORMAT) if obj.get('lastUsed') else None,
last_version_id=obj.get('lastVersionId', 'latest-release'),
profile_name=obj.get('name', ''),
profile_type=obj.get('type', 'latest-release'),
java_args=obj.get('javaArgs', ''),
game_dir=Path(obj['gameDir']) if obj.get('gameDir') else None
def get_servers():
for container in client.containers.list(filters={"ancestor": "itzg/minecraft-server"}, all=True):
image = container.image.tags[0]
env_list = container.attrs['Config']['Env']
yield PyPistonServer(
server_id=container.id,
server_name=get_env_value("SERVER_NAME", env_list),
container_name=container.name,
image=image,
version=get_env_value("VERSION", env_list) or "Vanilla",
description="",
motd=get_env_value("MOTD", env_list),
rcon_enabled=get_env_value("ENABLE_RCON", env_list) != "FALSE",
ports={k: v[0]['HostPort'] for k, v in container.ports.items() if v},
is_vanilla=get_env_value("TYPE", env_list) is None,
snooper_enabled=get_env_value("SNOOPER_ENABLED", env_list) == "TRUE",
status=([s for s in container.attrs['State'] if container.attrs['State'][s] is True] or ["Starting"])[0],
uptime=str(datetime.now().astimezone() - datetime.fromisoformat(container.attrs['Created'])),
world_name=get_env_value("LEVEL", env_list) or "world",
online_mode=get_env_value("ONLINE_MODE", env_list) != "FALSE",
allow_flight=get_env_value("ALLOW_FLIGHT", env_list) == "TRUE"
)
def to_json(self) -> dict:
return {
"created": self.dt_created.strftime(DT_FORMAT),
"gameDir": str(self.game_dir),
'icon': self.icon,
'javaArgs': self.java_args,
'lastUsed': self.last_used.strftime(DT_FORMAT),
'lastVersionId': self.last_version_id,
'name': self.profile_name,
'type': self.profile_type
}
@app.get("/", status_code=status.HTTP_200_OK)
def read_root():
return {"version": "0.1-dev"}
def get_minecraft_folder(**kwargs):
mc_path = kwargs.get('minecraft_root', Path(os.getenv('APPDATA')) / '.minecraft')
if mc_path.exists():
return mc_path
else:
return None
@app.get("/servers/")
def read_active_servers():
mc_servers = list(get_servers())
return {
"total": len(mc_servers),
"servers": mc_servers
}
def load_profiles(**kwargs):
mc_path = kwargs.get('minecraft_path', get_minecraft_folder())
profiles_file = mc_path / 'launcher_profiles.json'
profiles_list = []
if profiles_file.exists():
with open(profiles_file, 'r') as json_file:
profiles_json = json.load(json_file)
for profile in profiles_json['profiles']:
profile_obj = profiles_json['profiles'][profile]
profiles_list.append(
MinecraftProfile.from_json_obj(profile_obj, profile)
)
return profiles_list
def add_profile(profile: MinecraftProfile):
mc_path = get_minecraft_folder()
profiles_file = mc_path / 'launcher_profiles.json'
profiles_list = load_profiles()
if profile.profile_id in [p.profile_id for p in profiles_list]:
return 0
with open(profiles_file, 'r') as json_file:
json_raw = json.load(json_file)
json_raw['profiles'][profile.profile_id] = profile.to_json()
with open(profiles_file, 'w') as json_file:
json.dump(json_raw, json_file, indent=2)
return 1
def any_key():
return input("Press enter to continue...")
def package_mods(profile_name: str):
"""
Packages mods into a zip file for distribution
:param profile_name: The name of the profile to be deployed
:return:
"""
profiles_list = load_profiles()
profile_to_load = [profile for profile in profiles_list if profile.profile_name == profile_name]
if not profile_to_load:
raise IndexError(f"Profile {profile_name} does not exist!")
if len(profile_to_load) == 1:
profile_to_load = profile_to_load[0]
else:
raise IndexError(f"Multiple profiles match the name {profile_name}!")
# Create the zip
zip_file_name = profile_name.strip().lower().replace(' ', '_') + '.zip'
with zipfile.ZipFile(zip_file_name, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk(profile_to_load.game_dir):
for file in files:
if not file.endswith('.jar'):
# Skip all non-jar files
continue
zipf.write(
os.path.join(root, file),
os.path.relpath(
os.path.join(root, file),
os.getcwd()
)
)
def main():
log = logging.getLogger()
log.setLevel(logging.DEBUG)
log.addHandler(logging.StreamHandler(stream=stdout))
profiles = load_profiles()
forge_profiles = [profile for profile in profiles if 'forge' in profile.last_version_id]
log.info(f"Hello and welcome! I'm going to install the modpack this script was packaged with.")
log.info(f"You shouldn't have to do anything - I'll let you know if I found anything weird that I need help with.")
log.debug(f"Found {len(forge_profiles)} modded profiles")
if PROFILE_NAME in [profile_name.profile_name for profile_name in forge_profiles]:
log.info(f"Modpack is already installed, exiting!")
return 0
# Before we add the profile, we need to make sure forge is installed
forge_profile = [profile for profile in profiles if profile.profile_name == 'forge'
and profile.last_version_id == '1.19.2-forge-43.1.42']
if not forge_profile:
# We need to install the forge version we were packaged with!
# First, make sure java is installed and on the path:
java_version_output = os.system('java --version')
if java_version_output != 0:
# Java is not installed!
log.info(f"Java is not installed! Please install OpenJDK and run this script again.")
webbrowser.open("https://aka.ms/download-jdk/microsoft-jdk-17.0.4.1-windows-x64.msi")
any_key()
return 1
log.info(f"Forge needs to be installed - select \"install client\" and follow the on-screen prompts.")
log.info(f"Check back here when you're done!")
forge_install_output = os.system('java -jar forge-1.19.2-43.1.42-installer.jar')
if forge_install_output != 0:
log.error(f"The install command failed! Is java installed?")
any_key()
return 1
log.info(f"Modpack is not already installed - just needed to check that before continuing!")
modded_folder = Path(os.getenv('APPDATA')) / '.moddedminecraft' / '1.19.2' / PROFILE_NAME
if modded_folder.exists():
log.warning(f"Profile path already exists! Continuing, but this is weird!")
else:
try:
modded_folder.mkdir(parents=True, exist_ok=True)
except BaseException as e:
log.error(f"I ran into an error while trying to make the profile path... can you make it for me?\n"
f"The path needs to be here: {modded_folder}\n"
f"Once you create it, run this script again, and I should be good to go.",
exc_info=e)
any_key()
raise e
log.info(f"Created profile path successfully!")
modded_profile = MinecraftProfile(
profile_id=str(uuid.uuid4()).replace('-', ''),
profile_name=PROFILE_NAME,
dt_created=datetime.utcnow(),
icon='Furnace_On',
java_args=" ".join([
"-Xmx8G",
"-XX:+UnlockExperimentalVMOptions",
"-XX:+UseG1GC",
"-XX:G1NewSizePercent=20",
"-XX:G1ReservePercent=20",
"-XX:MaxGCPauseMillis=50",
"-XX:G1HeapRegionSize=32M"
]),
last_used=datetime.utcnow(),
last_version_id='1.19.2-forge-43.1.42',
profile_type='custom',
game_dir=modded_folder
@app.post("/servers/new")
def create_new_server(server: PyPistonServer):
env = {
"SERVER_NAME": server.server_name,
"MOTD": server.motd,
"ENABLE_RCON": server.rcon_enabled,
"SNOOPER_ENABLED": server.snooper_enabled,
"LEVEL": server.world_name,
"ONLINE_MODE": server.online_mode,
"ALLOW_FLIGHT": server.allow_flight
}
if server.version != "Vanilla":
env["VERSION"] = server.version
new_srv = client.containers.run(
server.image,
ports=server.ports,
restart_policy={"Name": "on-failure", "MaximumRetryCount": 5},
labels={"pyPistonManged": "True"},
environment=env,
detach=True
)
log.debug(f"Profile to be added:\n{json.dumps(modded_profile.to_json(), indent=4)}")
add_profile(modded_profile)
log.info(f"Profile added! Copying mod files...")
mods_folder = Path('mods')
dest_mods_folder = modded_profile.game_dir / 'mods'
dest_mods_folder.mkdir(parents=True, exist_ok=True)
copy_tree(str(mods_folder), str(dest_mods_folder))
log.info("All done!")
any_key()
new_srv = [s for s in get_servers() if s.server_id == new_srv.id][0]
return new_srv
if __name__ == '__main__':
main()
@app.get("/servers/{server_id}")
def get_server_by_id(server_id: str):
return [s for s in get_servers() if s.server_id == server_id][0]
@app.post("/servers/{server_id}/exec")
def run_cmd_on_server(server_id: str, command: str):
server = [s for s in get_servers() if s.server_id == server_id][0]
container = client.containers.get(server.server_id)
if server.rcon_enabled:
cmd = f"rcon-cli {command}"
else:
cmd = f"mc-send-to-console {command}"
result = container.exec_run(cmd)
return {
"exit_code": result.exit_code,
"output": result.output.decode("utf-8")
}

16
requirements.txt Normal file
View File

@ -0,0 +1,16 @@
anyio==3.6.2
click==8.1.3
colorama==0.4.6
fastapi==0.91.0
h11==0.14.0
httptools==0.5.0
idna==3.4
pydantic==1.10.4
python-dotenv==0.21.1
PyYAML==6.0
sniffio==1.3.0
starlette==0.24.0
typing_extensions==4.4.0
uvicorn==0.20.0
watchfiles==0.18.1
websockets==10.4