#!/usr/bin/env python3 import hashlib from typing import Any, Literal, 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"] 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: DownloadInfo client_mappings: DownloadInfo server: DownloadInfo server_mappings: DownloadInfo class VersionManifestFull(TypedDict): id: str mainClass: str type: ReleaseType time: str releaseTime: str minimumLauncherVersion: int javaVersion: 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) 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 default_build_platforms = ["linux/arm64/v8", "linux/arm/v7", "linux/amd64", "linux/ppc64le"] def docker_buildx(repository: str, tags: list[str], build_platforms: list[str] | None = None, dockerfile: str = "Dockerfile", build_args: DockerfileBuildArgs | dict[str, Any] | None = None, directory: str = "."): if build_platforms is None: build_platforms = default_build_platforms 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)) subprocess.run(command) 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['javaVersion'] print( f"Version [{version['type']}] {version['id']} requires java version {java_version['majorVersion']} ({java_version['component']})") server_jar_file = Path(f"versions/{version['id']}/server.jar") server_jar = version["downloads"]["server"] download_file(server_jar, server_jar_file) build_args: DockerfileBuildArgs = { "VERSION_ID": version['id'], "VERSION_SHA1": server_jar['sha1'], "DOCKER_IMAGE": "" } # Build GraalVM images build_args["DOCKER_IMAGE"] = f"ghcr.io/graalvm/jdk:java{java_version['majorVersion']}" docker_buildx(repository, [version["id"], f"{version['id']}-graalvm"], build_args=build_args, build_platforms=["linux/amd64", "linux/arm64"]) # Build Temurin build_args["DOCKER_IMAGE"] = f"eclipse-temurin:{java_version['majorVersion']}-jre" docker_buildx(repository, [f"{version['id']}-temurin"], 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.") 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 for version_id in versions: try: build_version(manifest, version_id) except Exception as e: print(f"Failed building images for version {version_id}:\n\t{e}")