#!/usr/bin/env python3 import hashlib from typing import Any, Literal, NotRequired, 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] def load_version_manifest(version: Version): # Download new manifest with urllib.request.urlopen(version["url"]) as url: data: VersionManifestFull = json.load(url) return data def load_manifest(reload: bool = False, max_age: timedelta = timedelta(hours=1)): manifest_cache = "manifest.cache" # Load cached manifest if reload: print("Forced manifest reload.") else: try: with open(manifest_cache, "rb") as f: time: datetime data: VersionManifest (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("No cached manifest found!") # Download new manifest with urllib.request.urlopen("https://launchermeta.mojang.com/mc/game/version_manifest.json") as url: data: VersionManifest = json.load(url) # Save manifest to cache with open(manifest_cache, "wb") as f: pickle.dump((datetime.now(), data), f) return data 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 = "."): 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] print(" ".join(command)) process = subprocess.run(command, stderr=subprocess.PIPE) if process.returncode != 0: 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']})") # 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']})") # Build Temurin 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}")