docker-minecraft-server/build.py

275 lines
9.6 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
import hashlib
2022-11-15 10:05:24 +01:00
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"]
2022-11-15 09:37:31 +01:00
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):
2022-11-15 09:29:32 +01:00
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]
2022-11-15 10:05:24 +01:00
T = TypeVar('T')
def load_json(url: str) -> Any:
with urllib.request.urlopen(url) as request:
return json.load(request)
cache_path = Path("cache")
2022-11-15 10:05:24 +01:00
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:
2022-11-15 10:05:24 +01:00
print(f"Forced cache reload for {cache_file}!")
else:
try:
2022-11-15 10:05:24 +01:00
with open(cache_file, "rb") as f:
time: datetime
2022-11-15 10:05:24 +01:00
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:
2022-11-15 10:05:24 +01:00
print(f"File {cache_file} not cached, will be reloaded!")
2022-11-15 10:05:24 +01:00
data = loader(url)
2022-11-15 10:05:24 +01:00
# 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
2022-11-15 10:05:24 +01:00
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():
2022-11-15 02:48:10 +01:00
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
2022-11-15 09:46:03 +01:00
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]
2022-11-15 09:46:03 +01:00
if write_command:
print(" ".join(command))
process = subprocess.run(
command, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
2022-11-15 09:42:05 +01:00
if process.returncode != 0:
2022-11-15 09:43:07 +01:00
output = process.stdout.decode('utf-8')
print(output)
2022-11-15 10:16:13 +01:00
error = "\n".join(line for line in process.stderr.decode(
'utf-8').splitlines() if 'ERROR' in line)
2022-11-15 09:42:05 +01:00
raise Exception(error)
default_java_version: JavaVersion = {
"component": "jre",
"majorVersion": 8
}
2022-11-15 09:37:31 +01:00
2022-11-15 09:29:32 +01:00
class NoServerException(Exception):
pass
2022-11-15 09:37:31 +01:00
def select_version(manifest: VersionManifest, version_id: str) -> Version:
match version_id:
case "latest":
return select_version(manifest, manifest["latest"]["release"])
case "snapshot":
return select_version(manifest, manifest["latest"]["snapshot"])
case _:
matching_version = next(filter(lambda v: v["id"] == version_id, manifest["versions"]), None)
if matching_version is None:
raise Exception(f"Version {version_id} not found in manifest!")
return matching_version
def build_version(manifest: VersionManifest, version_name: str, repository: str = "hub.cnml.de/minecraft"):
version = load_version_manifest(select_version(manifest, version_name))
java_version = version.get("javaVersion", default_java_version)
print(
f"Version [{version['type']}] {version['id']} requires java version {java_version['majorVersion']} ({java_version['component']})")
2022-11-15 02:48:10 +01:00
server_jar_file = Path("versions") / version['id'] / "server.jar"
2022-11-15 09:29:32 +01:00
server_jar = version["downloads"].get("server")
if server_jar is None:
2022-11-15 09:37:31 +01:00
raise NoServerException(
f"No server build exists for {version['id']} (Java {java_version['majorVersion']})")
2022-11-15 09:29:32 +01:00
download_file(server_jar, server_jar_file)
build_args: DockerfileBuildArgs = {
"VERSION_ID": version['id'],
"VERSION_SHA1": server_jar['sha1'],
"DOCKER_IMAGE": ""
}
2022-11-15 09:37:31 +01:00
print(
f"Building version {version['id']} (Java {java_version['majorVersion']}) as {version_name}")
2022-11-15 09:46:03 +01:00
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']}"
2022-11-15 09:37:31 +01:00
docker_buildx(repository, [
f"{version_name}-graalvm"], graal_platforms, build_args=build_args)
else:
2022-11-15 09:37:31 +01:00
print(
f"No GraalVM image can be built for {version['id']} (Java {java_version['majorVersion']}) as {version_name}")
2022-11-15 09:46:03 +01:00
print("# Build Temurin images")
2022-11-15 10:24:55 +01:00
if java_version['majorVersion'] != 16:
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, [f"{version_name}-temurin"], temurin_platforms, build_args=build_args)
2022-11-15 10:24:55 +01:00
else:
print(
f"No Temurin image can be built for {version['id']} (Java {java_version['majorVersion']}) as {version_name}")
2022-11-15 10:16:13 +01:00
print("# Build Zulu images")
build_args["DOCKER_IMAGE"] = f"azul/zulu-openjdk:{java_version['majorVersion']}-jre"
zulu_platforms: list[DockerPlatforms] = ["linux/arm64", "linux/amd64"]
docker_buildx(
repository, [version_name, f"{version_name}-zulu"], zulu_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)
all_versions = list(version["id"] for version in manifest["versions"] if version["type"] == "release")
all_versions.append("latest")
versions: list[str] = all_versions if args.all else args.versions
2022-11-15 09:37:31 +01:00
errors: list[tuple[str, Exception]] = []
for version_id in versions:
2022-11-15 09:29:32 +01:00
print()
try:
build_version(manifest, version_id)
2022-11-15 09:29:32 +01:00
except NoServerException as e:
print(e)
2022-11-15 09:37:31 +01:00
errors.append((version_id, e))
except Exception as e:
print(f"Failed building images for version {version_id}")
if args.force:
print(e)
2022-11-15 09:37:31 +01:00
errors.append((version_id, e))
else:
raise
2022-11-15 09:37:31 +01:00
for (version_id, e) in errors:
2022-11-15 09:46:03 +01:00
print(f"Error building version {version_id}:\n\t{e}")