diff --git a/pyPiston.py b/pyPiston.py index 320fb88..71297d6 100644 --- a/pyPiston.py +++ b/pyPiston.py @@ -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") + } diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3fa0b4d --- /dev/null +++ b/requirements.txt @@ -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