API Server initial commit
This commit is contained in:
parent
bce7314e43
commit
8e09c58ac5
364
pyPiston.py
364
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")
|
||||
}
|
||||
|
16
requirements.txt
Normal file
16
requirements.txt
Normal 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
|
Loading…
x
Reference in New Issue
Block a user