#!/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}")