275 lines
9.6 KiB
Python
Executable File
275 lines
9.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 = "\n".join(line for line in process.stderr.decode(
|
|
'utf-8').splitlines() if 'ERROR' in line)
|
|
raise Exception(error)
|
|
|
|
|
|
default_java_version: JavaVersion = {
|
|
"component": "jre",
|
|
"majorVersion": 8
|
|
}
|
|
|
|
|
|
class NoServerException(Exception):
|
|
pass
|
|
|
|
|
|
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']})")
|
|
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']}) as {version_name}")
|
|
|
|
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_name}-graalvm"], graal_platforms, build_args=build_args)
|
|
else:
|
|
print(
|
|
f"No GraalVM image can be built for {version['id']} (Java {java_version['majorVersion']}) as {version_name}")
|
|
|
|
print("# Build Temurin images")
|
|
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)
|
|
else:
|
|
print(
|
|
f"No Temurin image can be built for {version['id']} (Java {java_version['majorVersion']}) as {version_name}")
|
|
|
|
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
|
|
|
|
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}")
|