docker-minecraft-server/build.py

255 lines
8.6 KiB
Python
Executable File

#!/usr/bin/env python3
import hashlib
from typing import Any, Callable, Literal, NotRequired, TypeVar, TypedDict
from pathlib import Path
import urllib.request
import json
import pickle
import subprocess
import argparse
from datetime import datetime, timedelta
ReleaseType = Literal["release", "snapshot", "old_beta", "old_alpha"]
DockerPlatforms = Literal["linux/amd64", "linux/amd64/v2", "linux/amd64/v3", "linux/arm64", "linux/riscv64",
"linux/ppc64le", "linux/s390x", "linux/386", "linux/mips64le", "linux/mips64", "linux/arm/v7", "linux/arm/v6"]
supported_docker_platforms: list[DockerPlatforms] = ["linux/amd64", "linux/amd64/v2", "linux/amd64/v3", "linux/arm64",
"linux/riscv64", "linux/ppc64le", "linux/s390x", "linux/386", "linux/mips64le", "linux/mips64", "linux/arm/v7", "linux/arm/v6"]
class LatestVersions(TypedDict):
release: str
snapshot: str
class Version(TypedDict):
id: str
type: ReleaseType
url: str
time: str
releaseTime: str
class JavaVersion(TypedDict):
component: str
majorVersion: int
class DownloadInfo(TypedDict):
sha1: str
size: int
url: str
class VersionDownloads(TypedDict):
client: NotRequired[DownloadInfo]
client_mappings: NotRequired[DownloadInfo]
server: NotRequired[DownloadInfo]
server_mappings: NotRequired[DownloadInfo]
class VersionManifestFull(TypedDict):
id: str
mainClass: str
type: ReleaseType
time: str
releaseTime: str
minimumLauncherVersion: int
javaVersion: NotRequired[JavaVersion]
downloads: VersionDownloads
class DockerfileBuildArgs(TypedDict):
DOCKER_IMAGE: str
VERSION_ID: str
VERSION_SHA1: str
class VersionManifest(TypedDict):
latest: LatestVersions
versions: list[Version]
T = TypeVar('T')
def load_json(url: str) -> Any:
with urllib.request.urlopen(url) as request:
return json.load(request)
cache_path = Path("cache")
def load_cached_json(url: str, cache_file: Path, loader: Callable[[str], T] = load_json, max_age: timedelta = timedelta(days=1), reload: bool = False) -> T:
if reload:
print(f"Forced cache reload for {cache_file}!")
else:
try:
with open(cache_file, "rb") as f:
time: datetime
data: T
(time, data) = pickle.load(f)
cache_age = datetime.now() - time
if cache_age <= max_age:
print(f"Loaded cache file with age {cache_age}.")
return data
print(f"Cache is older than {max_age} and will be reloaded.")
except FileNotFoundError:
print(f"File {cache_file} not cached, will be reloaded!")
data = loader(url)
# Save to cache
cache_file.parent.mkdir(parents=True, exist_ok=True)
with open(cache_file, "wb") as f:
pickle.dump((datetime.now(), data), f)
return data
def load_version_manifest(version: Version, reload: bool = False) -> VersionManifestFull:
return load_cached_json(version["url"], cache_path / f"manifest_{version['id']}.cache", reload=reload)
def load_manifest(reload: bool = False) -> VersionManifest:
return load_cached_json("https://launchermeta.mojang.com/mc/game/version_manifest.json", cache_path / "manifest.cache", reload=reload)
BUF_SIZE = 65536
def calculate_hash(filename: Path):
with filename.open("rb") as f:
sha1 = hashlib.sha1()
while True:
data = f.read(BUF_SIZE)
if not data:
break
sha1.update(data)
return sha1
def download_file(file: DownloadInfo, filename: Path):
if not filename.exists():
filename.parent.mkdir(exist_ok=True, parents=True)
print(f"Downloading new file {file['url']}")
urllib.request.urlretrieve(file["url"], filename)
sha1 = calculate_hash(filename)
if sha1.hexdigest() == file["sha1"]:
print("Hashes match!")
else:
raise Exception(
f"File has sha1 {sha1.hexdigest()}, expected {file['sha1']}")
return filename
def docker_buildx(repository: str, tags: list[str], build_platforms: list[DockerPlatforms], dockerfile: str = "Dockerfile", build_args: DockerfileBuildArgs | dict[str, Any] | None = None, directory: str = ".", write_command: bool = False):
if build_args is None:
build_args = dict()
labels = [f"{repository}:{tag}"for tag in tags]
command = ["docker", "buildx", "build",
"--platform", ",".join(build_platforms),
*[t for (key, value) in build_args.items()
for t in ("--build-arg", f"{key}={value}")],
"--file", dockerfile,
*[t for label in labels for t in ("--tag", label)],
"--pull", "--push", directory]
if write_command:
print(" ".join(command))
process = subprocess.run(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
if process.returncode != 0:
output = process.stdout.decode('utf-8')
print(output)
error = process.stderr.decode('utf-8')
raise Exception(error)
default_java_version: JavaVersion = {
"component": "jre",
"majorVersion": 8
}
class NoServerException(Exception):
pass
def build_version(manifest: VersionManifest, version_id: str, repository: str = "hub.cnml.de/minecraft"):
matching_version = list(
filter(lambda v: v["id"] == version_id, manifest["versions"]))
if len(matching_version) == 0:
raise Exception(f"Version {version_id} not found in manifest!")
version = load_version_manifest(matching_version[0])
java_version = version.get("javaVersion", default_java_version)
print(
f"Version [{version['type']}] {version['id']} requires java version {java_version['majorVersion']} ({java_version['component']})")
server_jar_file = Path("versions") / version['id'] / "server.jar"
server_jar = version["downloads"].get("server")
if server_jar is None:
raise NoServerException(
f"No server build exists for {version['id']} (Java {java_version['majorVersion']})")
download_file(server_jar, server_jar_file)
build_args: DockerfileBuildArgs = {
"VERSION_ID": version['id'],
"VERSION_SHA1": server_jar['sha1'],
"DOCKER_IMAGE": ""
}
print(
f"Building version {version['id']} (Java {java_version['majorVersion']})")
print("# Build GraalVM images")
if java_version['majorVersion'] == 17:
graal_platforms: list[DockerPlatforms] = ["linux/arm64", "linux/amd64"]
build_args["DOCKER_IMAGE"] = f"ghcr.io/graalvm/jdk:java{java_version['majorVersion']}"
docker_buildx(repository, [
f"{version['id']}-graalvm"], graal_platforms, build_args=build_args)
else:
print(
f"No GraalVM image can be built for {version['id']} (Java {java_version['majorVersion']})")
print("# Build Temurin images")
build_args["DOCKER_IMAGE"] = f"eclipse-temurin:{java_version['majorVersion']}-jre"
temurin_platforms: list[DockerPlatforms] = [
"linux/arm64", "linux/arm/v7", "linux/amd64", "linux/ppc64le"]
docker_buildx(
repository, [version["id"], f"{version['id']}-temurin"], temurin_platforms, build_args=build_args)
if __name__ == "__main__":
parser = argparse.ArgumentParser(
"build", description="Utility script to build docker images for Minecraft servers.")
parser.add_argument("versions", nargs="*", type=str,
help="Versions to build for.")
parser.add_argument("--reload", action="store_true",
help="Force reload the manifest.")
parser.add_argument("--all", action="store_true",
help="Build all versions.")
parser.add_argument("--force", action="store_true",
help="Force continue on single version build failure.")
args = parser.parse_args()
manifest = load_manifest(args.reload)
versions: list[str] = list(version["id"] for version in manifest["versions"]
if version["type"] == "release") if args.all else args.versions
errors: list[tuple[str, Exception]] = []
for version_id in versions:
print()
try:
build_version(manifest, version_id)
except NoServerException as e:
print(e)
errors.append((version_id, e))
except Exception as e:
print(f"Failed building images for version {version_id}")
if args.force:
print(e)
errors.append((version_id, e))
else:
raise
for (version_id, e) in errors:
print(f"Error building version {version_id}:\n\t{e}")