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 from fastapi import FastAPI, status
# install forge client from pydantic import BaseModel, Field
# 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 pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime, timedelta
from distutils.dir_util import copy_tree import docker
import zipfile
# from StickyPiston import StickyPiston app = FastAPI()
client = docker.from_env()
DT_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' class PyPistonServer(BaseModel):
PROFILE_NAME = "William's Modded 1.19.2" 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 get_env_value(env_key: str, env_list: list):
def __init__( val = [e for e in env_list if f"{env_key}=" in e]
self, return val[0].split('=')[-1] if val else None
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
@classmethod
def from_json_obj(cls, obj, profile_id): def get_servers():
return cls( for container in client.containers.list(filters={"ancestor": "itzg/minecraft-server"}, all=True):
profile_id=profile_id, image = container.image.tags[0]
dt_created=datetime.strptime(obj['created'], DT_FORMAT) if obj.get('created') else None, env_list = container.attrs['Config']['Env']
icon=obj.get('icon', 'Crafting_Table'), yield PyPistonServer(
last_used=datetime.strptime(obj['lastUsed'], DT_FORMAT) if obj.get('lastUsed') else None, server_id=container.id,
last_version_id=obj.get('lastVersionId', 'latest-release'), server_name=get_env_value("SERVER_NAME", env_list),
profile_name=obj.get('name', ''), container_name=container.name,
profile_type=obj.get('type', 'latest-release'), image=image,
java_args=obj.get('javaArgs', ''), version=get_env_value("VERSION", env_list) or "Vanilla",
game_dir=Path(obj['gameDir']) if obj.get('gameDir') else None 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 { @app.get("/", status_code=status.HTTP_200_OK)
"created": self.dt_created.strftime(DT_FORMAT), def read_root():
"gameDir": str(self.game_dir), return {"version": "0.1-dev"}
'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
}
def get_minecraft_folder(**kwargs): @app.get("/servers/")
def read_active_servers():
mc_path = kwargs.get('minecraft_root', Path(os.getenv('APPDATA')) / '.minecraft') mc_servers = list(get_servers())
return {
if mc_path.exists(): "total": len(mc_servers),
return mc_path "servers": mc_servers
else: }
return None
def load_profiles(**kwargs): @app.post("/servers/new")
def create_new_server(server: PyPistonServer):
mc_path = kwargs.get('minecraft_path', get_minecraft_folder()) env = {
"SERVER_NAME": server.server_name,
profiles_file = mc_path / 'launcher_profiles.json' "MOTD": server.motd,
"ENABLE_RCON": server.rcon_enabled,
profiles_list = [] "SNOOPER_ENABLED": server.snooper_enabled,
if profiles_file.exists(): "LEVEL": server.world_name,
with open(profiles_file, 'r') as json_file: "ONLINE_MODE": server.online_mode,
profiles_json = json.load(json_file) "ALLOW_FLIGHT": server.allow_flight
for profile in profiles_json['profiles']: }
profile_obj = profiles_json['profiles'][profile] if server.version != "Vanilla":
profiles_list.append( env["VERSION"] = server.version
MinecraftProfile.from_json_obj(profile_obj, profile) new_srv = client.containers.run(
) server.image,
ports=server.ports,
return profiles_list restart_policy={"Name": "on-failure", "MaximumRetryCount": 5},
labels={"pyPistonManged": "True"},
environment=env,
def add_profile(profile: MinecraftProfile): detach=True
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
) )
new_srv = [s for s in get_servers() if s.server_id == new_srv.id][0]
log.debug(f"Profile to be added:\n{json.dumps(modded_profile.to_json(), indent=4)}") return new_srv
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()
if __name__ == '__main__': @app.get("/servers/{server_id}")
main() 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