diff --git a/flake.lock b/flake.lock index 693acd94..df82fe67 100644 --- a/flake.lock +++ b/flake.lock @@ -267,6 +267,22 @@ "type": "github" } }, + "gtnh-daily-updater": { + "flake": false, + "locked": { + "lastModified": 1782091902, + "narHash": "sha256-r8w3GwXQ3cjlYkRxHQ9ijvsxFZYT8jvr6/vxbPk7AH8=", + "owner": "Caedis", + "repo": "gtnh-daily-updater", + "rev": "56a1f7c3c4c7acca76f8500a2cefb9605ee93342", + "type": "github" + }, + "original": { + "owner": "Caedis", + "repo": "gtnh-daily-updater", + "type": "github" + } + }, "home-manager": { "inputs": { "nixpkgs": [ @@ -1034,6 +1050,7 @@ "inputs": { "agent-browser": "agent-browser", "flake-parts": "flake-parts_2", + "gtnh-daily-updater": "gtnh-daily-updater", "home-manager": "home-manager", "homebrew-cask": "homebrew-cask", "homebrew-core": "homebrew-core", diff --git a/flake.nix b/flake.nix index 32a0b849..dc1b74d3 100644 --- a/flake.nix +++ b/flake.nix @@ -9,6 +9,10 @@ flake-parts.url = "github:hercules-ci/flake-parts"; home-manager.inputs.nixpkgs.follows = "nixpkgs"; home-manager.url = "github:nix-community/home-manager"; + gtnh-daily-updater = { + url = "github:Caedis/gtnh-daily-updater"; + flake = false; + }; waybar-peek = { url = "github:rrvsh/waybar_peek"; inputs.nixpkgs.follows = "nixpkgs"; diff --git a/nix/hosts.nix b/nix/hosts.nix index 6465a4dc..ecd64fc8 100644 --- a/nix/hosts.nix +++ b/nix/hosts.nix @@ -76,8 +76,7 @@ in cfg.modules.nixos.steam cfg.modules.nixos.prismlauncher cfg.modules.nixos.daily-midnight-poweroff - cfg.modules.nixos.gtnh-server - cfg.modules.nixos.gtnh-backups + cfg.modules.nixos.gtnh-daily-server ( { config, diff --git a/nix/modules/gtnh-backups.nix b/nix/modules/gtnh-backups.nix deleted file mode 100644 index d18946d3..00000000 --- a/nix/modules/gtnh-backups.nix +++ /dev/null @@ -1,50 +0,0 @@ -{ - config.flake.modules.nixos.gtnh-backups = - { pkgs, ... }: - let - backupDir = "/srv/gtnh-backups"; - in - { - systemd = { - tmpfiles.rules = [ "d ${backupDir} 0750 root root -" ]; - services.gtnh-backup = { - description = "Backup GTNH server"; - serviceConfig = { - Type = "oneshot"; - ExecStart = pkgs.writeShellScript "gtnh-backup" '' - set -euo pipefail - - ts="$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)" - dest="${backupDir}/gtnh-$ts.tar.zst" - - echo "say Starting server backup" > /run/gtnh-server.stdin || true - echo "save-all" > /run/gtnh-server.stdin || true - ${pkgs.coreutils}/bin/sleep 10 - - ${pkgs.gnutar}/bin/tar \ - --exclude='/srv/gtnh/backups' \ - -C /srv \ - -I '${pkgs.zstd}/bin/zstd -T0 -10' \ - -cf "$dest" \ - gtnh - - ${pkgs.findutils}/bin/find ${backupDir} \ - -name 'gtnh-*.tar.zst' \ - -type f \ - -mtime +14 \ - -delete - - echo "say Server backup complete" > /run/gtnh-server.stdin || true - ''; - }; - }; - timers.gtnh-backup = { - wantedBy = [ "timers.target" ]; - timerConfig = { - OnCalendar = "03:30"; - Persistent = true; - }; - }; - }; - }; -} diff --git a/nix/modules/gtnh-daily-server.nix b/nix/modules/gtnh-daily-server.nix new file mode 100644 index 00000000..ac2eeb21 --- /dev/null +++ b/nix/modules/gtnh-daily-server.nix @@ -0,0 +1,487 @@ +# NixOS module for the reproducible GTNH Daily dedicated server. +# Plain-language docs live in docs/gtnh-daily/; implementation-notes.md explains the Nix, +# shell, Python, systemd, manifest, and backup choices in this file. +# This module defines identity, directories, bootstrap, update, rollback, service sandboxing, +# stdin control, firewall, and declared updater extras/excludes while intentionally excluding +# mutable world data from the declarative guarantee. +{ inputs, ... }: +{ + config.flake.modules.nixos.gtnh-daily-server = + { pkgs, ... }: + let + # Dedicated Linux user/group isolates Daily from the stable `/srv/gtnh` server. + user = "gtnh-daily"; + group = user; + unit = "gtnh-daily-server"; + # Root must be traversable so clients can SSH-read the published manifest files. + rootDir = "/var/lib/gtnh-daily"; + serverDir = "${rootDir}/server"; + backupDir = "${rootDir}/backups"; + cacheDir = "${rootDir}/cache"; + configDir = "${rootDir}/config"; + stdin = "/run/${unit}.stdin"; + lockFile = "/run/gtnh-daily-update.lock"; + # Latest Daily manifest URL used for server bootstrap/update discovery. + manifestUrl = "https://raw.githubusercontent.com/GTNewHorizons/DreamAssemblerXXL/master/releases/manifests/daily.json"; + currentManifest = "${rootDir}/current-manifest.json"; + currentManifestHash = "${rootDir}/current-manifest.sha256"; + # Daily listens on 25566 so it can coexist with the stable server on 25565. + port = 25566; + java = pkgs.jdk25_headless; + updater = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.gtnh-daily-updater; + tar = "${pkgs.gnutar}/bin/tar --use-compress-program=${pkgs.zstd}/bin/zstd --xattrs --acls"; + updaterEnv = "HOME=${rootDir} XDG_CACHE_HOME=${cacheDir} XDG_CONFIG_HOME=${configDir} PATH=${pkgs.git}/bin"; + # Desired server updater policy. These fields are owned by Nix: manual edits to + # `exclude_mods`/`extra_mods` are overwritten unless they are added here. + serverExcludeMods = [ "JourneyMap Server" ]; + serverExtraMods = { + GTNH-Web-Map = { + source = "github:GTNewHorizons/GTNH-Web-Map"; + side = "SERVER"; + match = "^gtnh-web-map-.*[0-9]\\.jar$"; + }; + MineMenu = { + source = "modrinth:mine-menu/HNivj4HD"; + side = "SERVER"; + }; + }; + serverStateJson = pkgs.writeText "gtnh-daily-server-state.json" ( + builtins.toJSON { + exclude_mods = serverExcludeMods; + extra_mods = serverExtraMods; + } + ); + # Reconcile only declared updater-state knobs; leave scanned mods, world data, logs, and configs alone. + reconcileState = pkgs.writeShellScript "gtnh-daily-server-reconcile-state" '' + # Fail on any JSON/IO error so services do not run with partially-written updater state. + set -euo pipefail + state=${serverDir}/.gtnh-daily-updater.json + [ -f "$state" ] || exit 0 + ${pkgs.python3}/bin/python3 - "$state" ${serverStateJson} <<'PY' + import json, sys + state_path, desired_path = sys.argv[1:] + with open(state_path) as f: + state = json.load(f) + with open(desired_path) as f: + desired = json.load(f) + changed = False + if state.get("exclude_mods") != desired["exclude_mods"]: + state["exclude_mods"] = desired["exclude_mods"] + changed = True + if state.get("extra_mods") != desired["extra_mods"]: + state["extra_mods"] = desired["extra_mods"] + changed = True + if changed: + with open(state_path, "w") as f: + json.dump(state, f, indent=2) + f.write("\n") + PY + ${pkgs.coreutils}/bin/chown ${user}:${group} "$state" + ''; + # Bootstrap creates a missing server from Daily artifacts and prepares updater/EULA state. + # It does not overwrite an existing bootstrapped server or delete World/. + bootstrapScript = pkgs.writeShellScript "gtnh-daily-bootstrap" '' + # Stop immediately on errors because a half-bootstrapped modded server is unsafe to start. + set -euo pipefail + ${pkgs.coreutils}/bin/install -d -o ${user} -g ${group} -m 0755 ${rootDir} + ${pkgs.coreutils}/bin/install -d -o ${user} -g ${group} -m 0750 ${serverDir} ${backupDir} ${cacheDir} ${configDir} + manifest="${currentManifest}" + # Fetch the latest published manifest if the server has not yet published an applied manifest. + if [ ! -s "$manifest" ]; then + tmp_manifest="$(${pkgs.coreutils}/bin/mktemp --tmpdir gtnh-daily-bootstrap-manifest.XXXXXX.json)" + ${pkgs.curl}/bin/curl --fail --location --silent --show-error ${manifestUrl} --output "$tmp_manifest" + ${pkgs.coreutils}/bin/install -o ${user} -g ${group} -m 0644 "$tmp_manifest" "$manifest" + ${pkgs.coreutils}/bin/sha256sum "$manifest" > ${currentManifestHash} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${currentManifestHash} + ${pkgs.coreutils}/bin/rm -f "$tmp_manifest" + fi + # Download/extract a server artifact only when core launch files are absent. + if [ ! -e ${serverDir}/java9args.txt ] || [ ! -e ${serverDir}/lwjgl3ify-forgePatches.jar ]; then + tmp="$(${pkgs.coreutils}/bin/mktemp -d --tmpdir gtnh-daily-bootstrap.XXXXXX)" + cleanup() { ${pkgs.coreutils}/bin/rm -rf "$tmp"; } + trap cleanup EXIT + auth=() + if [ -n "''${GITHUB_TOKEN:-}" ]; then + auth=(-H "Authorization: Bearer ''${GITHUB_TOKEN}") + fi + runs_url='https://api.github.com/repos/GTNewHorizons/DreamAssemblerXXL/actions/workflows/daily-modpack-build.yml/runs?status=success&per_page=20' + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "$runs_url" -o "$tmp/runs.json" + ${pkgs.python3}/bin/python3 - "$tmp/runs.json" > "$tmp/run_ids" <<'PY' + import json, sys + for run in json.load(open(sys.argv[1])).get("workflow_runs", []): + print(run["id"]) + PY + server_url="" + manifest_url="" + while read -r run_id; do + [ -n "$run_id" ] || continue + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "https://api.github.com/repos/GTNewHorizons/DreamAssemblerXXL/actions/runs/$run_id/artifacts" -o "$tmp/artifacts.json" + eval "$(${pkgs.python3}/bin/python3 - "$tmp/artifacts.json" <<'PY' + import json, shlex, sys + server = manifest = "" + for artifact in json.load(open(sys.argv[1])).get("artifacts", []): + if artifact.get("expired"): + continue + name = artifact.get("name", "") + if "server-java17-25.zip" in name: + server = artifact["archive_download_url"] + elif name.startswith("gtnh-daily-") and name.endswith("-manifest.json"): + manifest = artifact["archive_download_url"] + print("server_url=" + shlex.quote(server)) + print("manifest_url=" + shlex.quote(manifest)) + PY + )" + if [ -n "$server_url" ] && [ -n "$manifest_url" ]; then + break + fi + done < "$tmp/run_ids" + if [ -z "$server_url" ] || [ -z "$manifest_url" ]; then + echo "Could not find non-expired GTNH Daily server artifacts. Set GITHUB_TOKEN if GitHub requires authentication." >&2 + exit 1 + fi + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "$server_url" -o "$tmp/server-artifact.zip" + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "$manifest_url" -o "$tmp/manifest-artifact.zip" + ${pkgs.unzip}/bin/unzip -q "$tmp/server-artifact.zip" -d "$tmp/server" + ${pkgs.unzip}/bin/unzip -q "$tmp/manifest-artifact.zip" -d "$tmp/manifest" + ${pkgs.coreutils}/bin/cp -a "$tmp/server/." ${serverDir}/ + found_manifest="$(${pkgs.findutils}/bin/find "$tmp/manifest" -type f -name '*.json' | ${pkgs.coreutils}/bin/head -n1)" + if [ -n "$found_manifest" ]; then + ${pkgs.coreutils}/bin/install -o ${user} -g ${group} -m 0644 "$found_manifest" "$manifest" + ${pkgs.coreutils}/bin/sha256sum "$manifest" > ${currentManifestHash} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${currentManifestHash} + fi + ${pkgs.coreutils}/bin/chown -R ${user}:${group} ${serverDir} + fi + # Initialize updater state after files exist; init scans mods and config baselines. + if [ ! -f ${serverDir}/.gtnh-daily-updater.json ]; then + config_version="$(${pkgs.python3}/bin/python3 - ${currentManifest} <<'PY' + import json, sys + print(json.load(open(sys.argv[1]))["config"]) + PY + )" + ${pkgs.util-linux}/bin/runuser -u ${user} -- env ${updaterEnv} ${updater}/bin/gtnh-daily-updater init --instance-dir ${serverDir} --side server --config "$config_version" + fi + ${reconcileState} + if [ -f ${serverDir}/eula.txt ]; then + ${pkgs.gnused}/bin/sed -i 's/^eula=false/eula=true/' ${serverDir}/eula.txt + else + printf 'eula=true\n' > ${serverDir}/eula.txt + fi + ${pkgs.coreutils}/bin/chown ${user}:${group} ${serverDir}/eula.txt + ''; + stopScript = pkgs.writeShellScript "${unit}-stop" '' + set -euo pipefail + if [ -p ${stdin} ]; then + echo stop > ${stdin} || true + fi + timeout=120 + while kill -0 "$1" 2>/dev/null && [ "$timeout" -gt 0 ]; do + sleep 1 + timeout=$((timeout - 1)) + done + if kill -0 "$1" 2>/dev/null; then + kill -TERM "$1" + fi + ''; + backupScript = pkgs.writeShellScript "gtnh-daily-backup" '' + set -euo pipefail + if [ ! -d ${serverDir} ]; then + echo "${serverDir} does not exist" >&2 + exit 1 + fi + ${pkgs.coreutils}/bin/mkdir -p ${backupDir} + ts="$(${pkgs.coreutils}/bin/date -u +%Y%m%d-%H%M%S)" + dest="${backupDir}/pre-update-$ts.tar.zst" + ${tar} -C ${rootDir} -cf "$dest" server + ${pkgs.coreutils}/bin/chown ${user}:${group} "$dest" + ${pkgs.coreutils}/bin/ln -sfn "$(${pkgs.coreutils}/bin/basename "$dest")" ${backupDir}/latest.tar.zst + echo "$dest" + ''; + # Update applies a pinned manifest, backs up first, and republishes the exact applied manifest for clients. + updateScript = pkgs.writeShellScript "gtnh-daily-update" '' + # Treat every failure as fatal so backup/update/publish steps do not silently diverge. + set -euo pipefail + exec 9>${lockFile} + ${pkgs.util-linux}/bin/flock -n 9 + manifest="$(${pkgs.coreutils}/bin/mktemp --tmpdir gtnh-daily-manifest.XXXXXX.json)" + cleanup() { + ${pkgs.coreutils}/bin/rm -f "$manifest" + } + trap cleanup EXIT + ${pkgs.curl}/bin/curl --fail --location --silent --show-error ${manifestUrl} --output "$manifest" + ${pkgs.coreutils}/bin/chmod 0644 "$manifest" + was_active=0 + if ${pkgs.systemd}/bin/systemctl is-active --quiet ${unit}.service; then + was_active=1 + fi + ${pkgs.systemd}/bin/systemctl stop ${unit}.service || true + restart_if_needed() { + if [ "$was_active" = 1 ]; then + ${pkgs.systemd}/bin/systemctl start ${unit}.service || true + fi + } + finish() { + cleanup + restart_if_needed + } + trap finish EXIT + ${backupScript} + ${pkgs.util-linux}/bin/runuser -u ${user} -- \ + env ${updaterEnv} \ + ${updater}/bin/gtnh-daily-updater update --instance-dir ${serverDir} --manifest-file "$manifest" + ${pkgs.coreutils}/bin/install -o ${user} -g ${group} -m 0644 "$manifest" ${currentManifest} + ${pkgs.coreutils}/bin/sha256sum ${currentManifest} > ${currentManifestHash} + ${pkgs.coreutils}/bin/chown ${user}:${group} ${currentManifestHash} + restart_if_needed + cleanup + trap - EXIT + ''; + # Rollback restores the latest or selected pre-update backup and restarts the Daily server. + rollback = pkgs.writeShellScriptBin "gtnh-daily-rollback" '' + set -euo pipefail + if [ "$(${pkgs.coreutils}/bin/id -u)" != 0 ]; then + echo "Run as root: sudo gtnh-daily-rollback [backup.tar.zst]" >&2 + exit 1 + fi + backup="''${1:-}" + if [ -z "$backup" ]; then + backup="$(${pkgs.findutils}/bin/find ${backupDir} -maxdepth 1 -type f -name 'pre-update-*.tar.zst' -printf '%T@ %p\n' | ${pkgs.coreutils}/bin/sort -nr | ${pkgs.gawk}/bin/awk 'NR == 1 { print $2 }')" + fi + if [ -z "$backup" ] || [ ! -f "$backup" ]; then + echo "No backup found under ${backupDir}" >&2 + exit 1 + fi + ts="$(${pkgs.coreutils}/bin/date -u +%Y%m%d-%H%M%S)" + ${pkgs.systemd}/bin/systemctl stop ${unit}.service || true + if [ -e ${serverDir} ]; then + ${pkgs.coreutils}/bin/mv ${serverDir} ${rootDir}/server.rollback-$ts + fi + ${tar} -C ${rootDir} -xf "$backup" + ${pkgs.coreutils}/bin/chown -R ${user}:${group} ${serverDir} + ${pkgs.systemd}/bin/systemctl start ${unit}.service + echo "Rolled back ${serverDir} from $backup" + ''; + # Restore only the active Minecraft world from the latest or selected FTBUtilities/ServerUtilities zip backup. + ftbuRollback = pkgs.writeShellScriptBin "gtnh-daily-ftbu-rollback" '' + set -euo pipefail + if [ "$(${pkgs.coreutils}/bin/id -u)" != 0 ]; then + echo "Run as root: sudo gtnh-daily-ftbu-rollback [backup.zip]" >&2 + exit 1 + fi + if [ "$#" -gt 1 ]; then + echo "Usage: sudo gtnh-daily-ftbu-rollback [backup.zip]" >&2 + exit 1 + fi + backup="''${1:-}" + if [ -z "$backup" ]; then + backup="$(${pkgs.findutils}/bin/find ${serverDir}/backups -maxdepth 1 -type f -name '*.zip' -printf '%T@ %p\n' | ${pkgs.coreutils}/bin/sort -nr | ${pkgs.gawk}/bin/awk 'NR == 1 { print $2 }')" + fi + if [ -z "$backup" ] || [ ! -f "$backup" ]; then + echo "No FTBUtilities/ServerUtilities zip backup found under ${serverDir}/backups" >&2 + exit 1 + fi + world="$(${pkgs.gawk}/bin/awk -F= '$1 == "level-name" { print $2 }' ${serverDir}/server.properties)" + if [ -z "$world" ]; then + echo "Could not read level-name from ${serverDir}/server.properties" >&2 + exit 1 + fi + ts="$(${pkgs.coreutils}/bin/date -u +%Y%m%d-%H%M%S)" + work="$(${pkgs.coreutils}/bin/mktemp -d --tmpdir gtnh-daily-ftbu-rollback.XXXXXX)" + cleanup() { + ${pkgs.coreutils}/bin/rm -rf "$work" + } + trap cleanup EXIT + ${pkgs.python3}/bin/python3 - "$backup" "$work" "$world" <<'PY' + import os + import sys + import zipfile + backup, work, world = sys.argv[1:] + with zipfile.ZipFile(backup) as z: + top_level = sorted({name.split('/', 1)[0] for name in z.namelist() if name and not name.startswith('/')}) + z.extractall(work) + if not os.path.isfile(os.path.join(work, world, 'level.dat')): + found = [] + for root, _, files in os.walk(work): + if 'level.dat' in files: + found.append(os.path.relpath(root, work)) + print(f"Backup {backup} does not contain active world {world!r} with level.dat", file=sys.stderr) + if found: + print('Worlds found in backup:', file=sys.stderr) + for path in sorted(found): + print(f" {path}", file=sys.stderr) + sys.exit(1) + with open(os.path.join(work, 'top-level'), 'w') as f: + for name in top_level: + if name: + f.write(name + '\n') + PY + ${pkgs.systemd}/bin/systemctl stop ${unit}.service || true + while IFS= read -r path; do + [ -n "$path" ] || continue + if [ -e "${serverDir}/$path" ]; then + ${pkgs.coreutils}/bin/mv "${serverDir}/$path" "${serverDir}/$path.pre-ftbu-restore-$ts" + fi + parent="$(${pkgs.coreutils}/bin/dirname "${serverDir}/$path")" + ${pkgs.coreutils}/bin/mkdir -p "$parent" + ${pkgs.coreutils}/bin/mv "$work/$path" "${serverDir}/$path" + ${pkgs.coreutils}/bin/chown -R ${user}:${group} "${serverDir}/$path" + echo "Restored ${serverDir}/$path from $backup" + done < "$work/top-level" + ${pkgs.systemd}/bin/systemctl start ${unit}.service + echo "Previous restored paths were moved aside with suffix .pre-ftbu-restore-$ts" + ''; + in + { + # System user/group declaration makes ownership reproducible across rebuilds. + users = { + users.${user} = { + description = "GT New Horizons daily server user"; + isSystemUser = true; + inherit group; + home = rootDir; + homeMode = "0755"; + createHome = true; + }; + groups.${group} = { }; + }; + environment.systemPackages = [ + rollback + ftbuRollback + ]; + systemd = { + # Tmpfiles enforces directory existence/modes without overwriting mutable contents. + tmpfiles.rules = [ + "d ${rootDir} 0755 ${user} ${group} -" + "d ${serverDir} 0750 ${user} ${group} -" + "d ${backupDir} 0750 ${user} ${group} -" + "d ${cacheDir} 0750 ${user} ${group} -" + "d ${configDir} 0750 ${user} ${group} -" + ]; + # FIFO socket provides a safe stdin path for sending Minecraft console commands via systemd. + sockets.${unit} = { + bindsTo = [ "${unit}.service" ]; + socketConfig = { + ListenFIFO = stdin; + SocketMode = "0660"; + SocketUser = user; + SocketGroup = group; + RemoveOnStop = true; + FlushPending = true; + }; + }; + services = { + # Bootstrap is a oneshot dependency of the main server and is safe to rerun. + gtnh-daily-bootstrap = { + description = "Bootstrap GT New Horizons daily server declaratively"; + wantedBy = [ "multi-user.target" ]; + before = [ "${unit}.service" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + serviceConfig = { + Type = "oneshot"; + ExecStart = bootstrapScript; + }; + }; + # Main dedicated server JVM service. + ${unit} = { + description = "GT New Horizons daily server"; + wantedBy = [ "multi-user.target" ]; + requires = [ + "${unit}.socket" + "gtnh-daily-bootstrap.service" + ]; + after = [ + "network-online.target" + "${unit}.socket" + "gtnh-daily-bootstrap.service" + ]; + wants = [ + "network-online.target" + "gtnh-daily-bootstrap.service" + ]; + unitConfig.ConditionPathExists = [ + "${serverDir}/java9args.txt" + "${serverDir}/lwjgl3ify-forgePatches.jar" + ]; + path = [ + java + pkgs.bash + pkgs.coreutils + pkgs.gnused + ]; + preStart = '' + if [ -f server.properties ]; then + if grep -q '^server-port=' server.properties; then + sed -i 's/^server-port=.*/server-port=${toString port}/' server.properties + else + printf '\nserver-port=${toString port}\n' >> server.properties + fi + fi + ''; + serviceConfig = { + User = user; + Group = group; + WorkingDirectory = serverDir; + Restart = "on-failure"; + RestartSec = "30s"; + SuccessExitStatus = "0 143"; + StandardInput = "socket"; + StandardOutput = "journal"; + StandardError = "journal"; + ExecStart = '' + ${java}/bin/java \ + -Xms6G \ + -Xmx10G \ + -XX:+UseZGC \ + -Dfml.readTimeout=180 \ + -Dfml.queryResult=confirm \ + @java9args.txt \ + -jar lwjgl3ify-forgePatches.jar \ + nogui + ''; + ExecStop = "${stopScript} $MAINPID"; + UMask = "0027"; + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ReadWritePaths = [ rootDir ]; + ProtectHome = true; + }; + }; + # Scheduled updater service; it reconciles desired updater state before applying Daily updates. + gtnh-daily-update = { + description = "Update GT New Horizons daily server"; + after = [ + "network-online.target" + "gtnh-daily-bootstrap.service" + ]; + wants = [ + "network-online.target" + "gtnh-daily-bootstrap.service" + ]; + unitConfig.ConditionPathExists = [ "${serverDir}/.gtnh-daily-updater.json" ]; + serviceConfig = { + Type = "oneshot"; + ExecStartPre = reconcileState; + ExecStart = updateScript; + }; + }; + }; + # Timer schedules Daily updates around 05:00 with jitter and catch-up behavior. + timers.gtnh-daily-update = { + description = "Update GT New Horizons daily server on schedule"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = "*-*-* 05:00:00"; + Persistent = true; + RandomizedDelaySec = "30m"; + }; + }; + }; + # Open only the Minecraft Daily port here; web-map exposure is handled separately if desired. + networking.firewall = { + allowedTCPPorts = [ port ]; + allowedUDPPorts = [ port ]; + }; + }; +} diff --git a/nix/modules/gtnh-server.nix b/nix/modules/gtnh-server.nix deleted file mode 100644 index b53f7372..00000000 --- a/nix/modules/gtnh-server.nix +++ /dev/null @@ -1,102 +0,0 @@ -{ - config.flake.modules.nixos.gtnh-server = - { pkgs, ... }: - let - gtnhDir = "/srv/gtnh"; - java = pkgs.jdk25_headless; - stopScript = pkgs.writeShellScript "gtnh-stop" '' - set -euo pipefail - - if [ -p /run/gtnh-server.stdin ]; then - echo "stop" > /run/gtnh-server.stdin || true - fi - - timeout=120 - while kill -0 "$1" 2>/dev/null && [ "$timeout" -gt 0 ]; do - sleep 1 - timeout=$((timeout - 1)) - done - - if kill -0 "$1" 2>/dev/null; then - kill -TERM "$1" - fi - ''; - in - { - users = { - users.gtnh = { - description = "GT New Horizons server user"; - isSystemUser = true; - group = "gtnh"; - home = gtnhDir; - createHome = true; - }; - groups.gtnh = { }; - }; - systemd = { - tmpfiles.rules = [ "d ${gtnhDir} 0750 gtnh gtnh -" ]; - sockets.gtnh-server = { - bindsTo = [ "gtnh-server.service" ]; - socketConfig = { - ListenFIFO = "/run/gtnh-server.stdin"; - SocketMode = "0660"; - SocketUser = "gtnh"; - SocketGroup = "gtnh"; - RemoveOnStop = true; - FlushPending = true; - }; - }; - services.gtnh-server = { - description = "GT New Horizons 2.8.4 Server"; - wantedBy = [ "multi-user.target" ]; - requires = [ "gtnh-server.socket" ]; - after = [ - "network-online.target" - "gtnh-server.socket" - ]; - wants = [ "network-online.target" ]; - unitConfig.ConditionPathExists = [ - "${gtnhDir}/java9args.txt" - "${gtnhDir}/lwjgl3ify-forgePatches.jar" - ]; - path = [ - java - pkgs.bash - pkgs.coreutils - ]; - serviceConfig = { - User = "gtnh"; - Group = "gtnh"; - WorkingDirectory = gtnhDir; - Restart = "on-failure"; - RestartSec = "30s"; - SuccessExitStatus = "0 143"; - StandardInput = "socket"; - StandardOutput = "journal"; - StandardError = "journal"; - ExecStart = '' - ${java}/bin/java \ - -Xms6G \ - -Xmx10G \ - -XX:+UseZGC \ - -Dfml.readTimeout=180 \ - @java9args.txt \ - -jar lwjgl3ify-forgePatches.jar \ - nogui - ''; - ExecStop = "${stopScript} $MAINPID"; - UMask = "0027"; - NoNewPrivileges = true; - PrivateTmp = true; - ProtectSystem = "strict"; - ReadWritePaths = [ gtnhDir ]; - ProtectHome = true; - }; - }; - }; - networking.firewall = { - allowedTCPPorts = [ 25565 ]; - allowedUDPPorts = [ 25565 ]; - }; - }; -} diff --git a/nix/modules/prismlauncher.nix b/nix/modules/prismlauncher.nix index 88c01688..021e249a 100644 --- a/nix/modules/prismlauncher.nix +++ b/nix/modules/prismlauncher.nix @@ -1,7 +1,15 @@ -{ config, ... }: +# Prism Launcher and GTNH Daily client module. +# Plain-language docs live in docs/gtnh-daily/; implementation-notes.md explains the Nix, +# shell, Python, systemd-user, manifest, resource-pack, and shader-pack choices in this file. +# Every declaration below exists to make the launcher, client updater state, resource packs, +# shader packs, desktop entries, and user sync timer reproducible after a wipe/rebuild. +{ config, inputs, ... }: let + # Flake-parts module namespace used to wire platform modules to the Home Manager module. cfg = config.flake; + # Shared OS wrapper: NixOS/Darwin import this and receive the Home Manager Prism module. osModule = { + # Inject Prism configuration into Home Manager rather than duplicating it per host OS. home-manager.sharedModules = [ cfg.modules.homeManager.prismlauncher ]; }; in @@ -10,18 +18,358 @@ in darwin.prismlauncher = osModule; nixos.prismlauncher = osModule; homeManager.prismlauncher = - { config, pkgs, ... }: { - xdg.desktopEntries.gtnh = { - name = "GregTech New Horizons"; - icon = "${config.home.homeDirectory}/.local/share/PrismLauncher/instances/GT_New_Horizons_2.8.4_Java_17-25/icon.png"; - exec = "prismlauncher --launch GT_New_Horizons_2.8.4_Java_17-25"; + config, + lib, + pkgs, + ... + }: + let + # Exact Prism instance name used by launcher metadata, desktop entries, and sync scripts. + gtnhDailyInstanceName = "GT New Horizons (Daily)"; + gtnhDailyInstanceDir = "${config.home.homeDirectory}/.local/share/PrismLauncher/instances/${gtnhDailyInstanceName}"; + # Keep only the newest successful pre-sync backups; each archive is about 2 GiB. + gtnhDailyBackupRetentionCount = 5; + # Repo-built updater includes our local manifest pinning patch. + gtnhDailyUpdater = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.gtnh-daily-updater; + # Resource/shader pack artifacts are downloaded at bootstrap time instead of committed to git. + # Updating a pack means changing its URL/name/hash together; bootstrap verifies the hash before use. + clientAssetsJson = pkgs.writeText "gtnh-daily-client-assets.json" ( + builtins.toJSON { + resourcePacks = [ + { + name = "AE2-Dark-Mode.v.1.18.zip"; + url = "https://github.com/Ranzuu/AE2-Dark-Mode/releases/download/v.1.18/AE2-Dark-Mode.v.1.18.zip"; + sha256 = "d8521075c02fecaad6b11f6c9766da7b195ae5d24497da280f755da0a52e23b0"; + extractDir = "AE2-Dark-Mode.v.1.18"; + stripRoot = false; + } + { + name = "Shadow.UI.v5.30-Modernity.version.zip"; + url = "https://github.com/Ranzuu/Shadow-UI/releases/download/v2.9.X/v5.30/Shadow.UI.v5.30-Modernity.version.zip"; + sha256 = "a1a3dd250f42a3ce4c904cb1fef1f6bec313c23869e6591be1fd957a10cc9655"; + extractDir = "shadowui"; + stripRoot = false; + } + { + name = "Modernity-GTNH-main.zip"; + url = "https://github.com/ModernityGTNH/Modernity-GTNH/archive/c3cd734cf5b912debdbcf75b9a88509d19f8fdfa.zip"; + sha256 = "d629e5b6022b208ef3d5707ad95712a7e4d5ff516e5151786fae051d232e6213"; + extractDir = "Modernity-GTNH-main"; + stripRoot = true; + } + ]; + shaderPacks = [ + { + name = "ComplementaryReimagined_r5.8.1.zip"; + url = "https://cdn.modrinth.com/data/HVnmMxH1/versions/yCCduG44/ComplementaryReimagined_r5.8.1.zip"; + sha256 = "3f1cd389e717b2e62f58edff222059b9c60de71b14bb49b517eb58318ce35b15"; + } + { + name = "ComplementaryUnbound_r5.8.1.zip"; + url = "https://cdn.modrinth.com/data/R6NEzAwj/versions/VMHXIk50/ComplementaryUnbound_r5.8.1.zip"; + sha256 = "bb89b1fc54687d4147a837fb2e3c3f7261a13bee51819761e9b6a91cb7915965"; + } + ]; + } + ); + # Desired client updater policy. This field is owned by Nix: manual edits to + # `extra_mods` are overwritten unless they are added here. + clientStateJson = pkgs.writeText "gtnh-daily-client-state.json" ( + builtins.toJSON { + extra_mods = { + JourneyMap = { + source = "github:TeamJM/journeymap-legacy"; + side = "CLIENT"; + match = "unlimited\\.jar$"; + }; + MineMenu = { + source = "modrinth:mine-menu/HNivj4HD"; + side = "CLIENT"; + }; + }; + } + ); + # Bootstrap creates/repairs the Prism instance, updater state, packs, and selected resource packs. + # It refuses to run while Prism/Minecraft appears active to avoid changing files under a running game. + gtnhDailyClientBootstrap = pkgs.writeShellScriptBin "gtnh-daily-client-bootstrap" '' + set -euo pipefail + + server="''${GTNH_DAILY_SERVER:-nemesis}" + remote_manifest="/var/lib/gtnh-daily/current-manifest.json" + remote_hash="/var/lib/gtnh-daily/current-manifest.sha256" + instance_dir="${gtnhDailyInstanceDir}" + game_dir="$instance_dir/.minecraft" + state_file="$instance_dir/.gtnh-daily-updater.json" + cache_dir="''${XDG_CACHE_HOME:-$HOME/.cache}/gtnh-daily-client-sync" + local_manifest="$cache_dir/current-manifest.json" + local_hash="$cache_dir/current-manifest.sha256" + + # Create local cache and Prism instance parent directories before network/bootstrap work. + mkdir -p "$cache_dir" "$HOME/.local/share/PrismLauncher/instances" + if ${pkgs.procps}/bin/pgrep -u "$(${pkgs.coreutils}/bin/id -u)" -f 'prismlauncher|net\.minecraft|minecraft|lwjgl3ify' >/dev/null; then + echo "Prism or Minecraft appears to be running; refusing to bootstrap the client instance." >&2 + exit 1 + fi + fetched_hash="$(${pkgs.openssh}/bin/ssh -o BatchMode=yes "$server" "cat '$remote_hash'")" + ${pkgs.openssh}/bin/scp -q -o BatchMode=yes "$server:$remote_manifest" "$local_manifest" + expected_hash="$(printf '%s\n' "$fetched_hash" | ${pkgs.gawk}/bin/awk '{ print $1 }')" + actual_hash="$(${pkgs.coreutils}/bin/sha256sum "$local_manifest" | ${pkgs.gawk}/bin/awk '{ print $1 }')" + if [ "$expected_hash" != "$actual_hash" ]; then + echo "Manifest hash mismatch: expected $expected_hash, got $actual_hash" >&2 + exit 1 + fi + + # Only download/extract the Prism artifact when the game directory is missing. + if [ ! -d "$game_dir" ]; then + tmp="$(${pkgs.coreutils}/bin/mktemp -d --tmpdir gtnh-daily-client-bootstrap.XXXXXX)" + cleanup() { ${pkgs.coreutils}/bin/rm -rf "$tmp"; } + trap cleanup EXIT + auth=() + if [ -n "''${GITHUB_TOKEN:-}" ]; then + auth=(-H "Authorization: Bearer ''${GITHUB_TOKEN}") + fi + runs_url='https://api.github.com/repos/GTNewHorizons/DreamAssemblerXXL/actions/workflows/daily-modpack-build.yml/runs?status=success&per_page=20' + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "$runs_url" -o "$tmp/runs.json" + ${pkgs.python3}/bin/python3 - "$tmp/runs.json" > "$tmp/run_ids" <<'PY' + import json, sys + for run in json.load(open(sys.argv[1])).get("workflow_runs", []): + print(run["id"]) + PY + client_url="" + while read -r run_id; do + [ -n "$run_id" ] || continue + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "https://api.github.com/repos/GTNewHorizons/DreamAssemblerXXL/actions/runs/$run_id/artifacts" -o "$tmp/artifacts.json" + client_url="$(${pkgs.python3}/bin/python3 - "$tmp/artifacts.json" <<'PY' + import json, sys + for artifact in json.load(open(sys.argv[1])).get("artifacts", []): + name = artifact.get("name", "") + if not artifact.get("expired") and "mmcprism-java17-25.zip" in name: + print(artifact["archive_download_url"]) + break + PY + )" + [ -n "$client_url" ] && break + done < "$tmp/run_ids" + if [ -z "$client_url" ]; then + echo "Could not find non-expired GTNH Daily Prism artifacts. Set GITHUB_TOKEN if GitHub requires authentication." >&2 + exit 1 + fi + ${pkgs.curl}/bin/curl --fail --location --silent --show-error "''${auth[@]}" "$client_url" -o "$tmp/client-artifact.zip" + ${pkgs.unzip}/bin/unzip -q "$tmp/client-artifact.zip" -d "$tmp/client" + ${pkgs.coreutils}/bin/mkdir -p "$instance_dir" + ${pkgs.coreutils}/bin/cp -a "$tmp/client/." "$instance_dir/" + fi + + config_version="$(${pkgs.python3}/bin/python3 - "$local_manifest" <<'PY' + import json, sys + print(json.load(open(sys.argv[1]))["config"]) + PY + )" + # Initialize gtnh-daily-updater state only when missing; existing state is reconciled below. + if [ ! -f "$state_file" ]; then + PATH=${pkgs.git}/bin:$PATH ${gtnhDailyUpdater}/bin/gtnh-daily-updater init --instance-dir "$instance_dir" --side client --config "$config_version" + fi + ${pkgs.python3}/bin/python3 - "$state_file" ${clientStateJson} <<'PY' + import json, sys + state_path, desired_path = sys.argv[1:] + state = json.load(open(state_path)) + desired = json.load(open(desired_path)) + changed = False + if state.get("extra_mods") != desired["extra_mods"]: + state["extra_mods"] = desired["extra_mods"] + changed = True + if changed: + with open(state_path, "w") as f: + json.dump(state, f, indent=2) + f.write("\n") + PY + # Ensure resource/shader directories exist, then fetch declared non-world client assets. + mkdir -p "$game_dir/resourcepacks" "$game_dir/shaderpacks" + ${pkgs.python3}/bin/python3 - ${clientAssetsJson} "$game_dir" <<'PY' + import json, os, shutil, subprocess, sys, tempfile, zipfile + assets_path, game_dir = sys.argv[1:] + with open(assets_path) as f: + assets = json.load(f) + resourcepacks = os.path.join(game_dir, "resourcepacks") + shaderpacks = os.path.join(game_dir, "shaderpacks") + def sha256(path): + out = subprocess.check_output(["${pkgs.coreutils}/bin/sha256sum", path], text=True) + return out.split()[0] + def download(url, dest, expected): + if os.path.exists(dest) and sha256(dest) == expected: + return + tmp = dest + ".tmp" + subprocess.check_call(["${pkgs.curl}/bin/curl", "--fail", "--location", "--silent", "--show-error", url, "--output", tmp]) + actual = sha256(tmp) + if actual != expected: + os.remove(tmp) + raise SystemExit(f"hash mismatch for {dest}: expected {expected}, got {actual}") + os.replace(tmp, dest) + def extract_resource(spec): + dest = os.path.join(resourcepacks, spec["name"]) + download(spec["url"], dest, spec["sha256"]) + extract_dir = os.path.join(resourcepacks, spec["extractDir"]) + marker = extract_dir + ".sha256" + if os.path.exists(extract_dir) and os.path.exists(marker) and open(marker).read().strip() == spec["sha256"]: + return + shutil.rmtree(extract_dir, ignore_errors=True) + with tempfile.TemporaryDirectory(dir=resourcepacks) as tmpdir: + with zipfile.ZipFile(dest) as zf: + zf.extractall(tmpdir) + if spec.get("stripRoot"): + entries = os.listdir(tmpdir) + if len(entries) != 1 or not os.path.isdir(os.path.join(tmpdir, entries[0])): + raise SystemExit(f"expected one top-level directory in {dest}") + shutil.move(os.path.join(tmpdir, entries[0]), extract_dir) + else: + os.makedirs(extract_dir, exist_ok=True) + for entry in os.listdir(tmpdir): + shutil.move(os.path.join(tmpdir, entry), os.path.join(extract_dir, entry)) + with open(marker, "w") as f: + f.write(spec["sha256"] + "\n") + for spec in assets["resourcePacks"]: + extract_resource(spec) + for spec in assets["shaderPacks"]: + download(spec["url"], os.path.join(shaderpacks, spec["name"]), spec["sha256"]) + with open(os.path.join(shaderpacks, "ComplementaryUnbound_r5.8.1.zip.txt"), "w") as f: + f.write("LIGHT_COLOR_MULTS=true\nLIGHT_NIGHT_I=0.01\n") + for root in (resourcepacks, shaderpacks, os.path.join(game_dir, ".gtnh-configs", "resourcepacks")): + if os.path.exists(root): + for dirpath, dirnames, filenames in os.walk(root): + os.chmod(dirpath, 0o755) + for name in filenames: + try: + os.chmod(os.path.join(dirpath, name), 0o644) + except FileNotFoundError: + pass + PY + if [ -f "$game_dir/options.txt" ]; then + ${pkgs.python3}/bin/python3 - "$game_dir/options.txt" <<'PY' + import sys + path = sys.argv[1] + with open(path) as f: + lines = f.read().splitlines() + resource = 'resourcePacks:["AE2-Dark-Mode.v.1.18","shadowui","Modernity-GTNH-main"]' + for i, line in enumerate(lines): + if line.startswith('resourcePacks:'): + lines[i] = resource + break + else: + lines.append(resource) + with open(path, 'w') as f: + f.write('\n'.join(lines) + '\n') + PY + fi + ''; + # Sync is the recurring command: bootstrap first, then update to the server-published manifest. + gtnhDailyClientSync = pkgs.writeShellScriptBin "gtnh-daily-client-sync" '' + set -euo pipefail + + server="''${GTNH_DAILY_SERVER:-nemesis}" + remote_manifest="/var/lib/gtnh-daily/current-manifest.json" + remote_hash="/var/lib/gtnh-daily/current-manifest.sha256" + instance_dir="${gtnhDailyInstanceDir}" + state_file="$instance_dir/.gtnh-daily-updater.json" + cache_dir="''${XDG_CACHE_HOME:-$HOME/.cache}/gtnh-daily-client-sync" + backup_dir="$HOME/.local/share/PrismLauncher/backups" + local_manifest="$cache_dir/current-manifest.json" + local_hash="$cache_dir/current-manifest.sha256" + lock_file="$cache_dir/sync.lock" + + mkdir -p "$cache_dir" + exec 9>"$lock_file" + ${pkgs.util-linux}/bin/flock -n 9 + + # Refuse mutation while Prism/Minecraft is running to avoid jar/config replacement races. + if ${pkgs.procps}/bin/pgrep -u "$(${pkgs.coreutils}/bin/id -u)" -f 'prismlauncher|net\.minecraft|minecraft|lwjgl3ify' >/dev/null; then + echo "Prism or Minecraft appears to be running; refusing to update the client instance." >&2 + exit 1 + fi + ${gtnhDailyClientBootstrap}/bin/gtnh-daily-client-bootstrap + + fetched_hash="$(${pkgs.openssh}/bin/ssh -o BatchMode=yes "$server" "cat '$remote_hash'")" + if [ -f "$local_hash" ] && [ "$fetched_hash" = "$(cat "$local_hash")" ]; then + echo "GTNH Daily client is already synced to $server." + exit 0 + fi + + ${pkgs.openssh}/bin/scp -q -o BatchMode=yes "$server:$remote_manifest" "$local_manifest" + expected_hash="$(printf '%s\n' "$fetched_hash" | ${pkgs.gawk}/bin/awk '{ print $1 }')" + actual_hash="$(${pkgs.coreutils}/bin/sha256sum "$local_manifest" | ${pkgs.gawk}/bin/awk '{ print $1 }')" + if [ "$expected_hash" != "$actual_hash" ]; then + echo "Manifest hash mismatch: expected $expected_hash, got $actual_hash" >&2 + exit 1 + fi + + mkdir -p "$backup_dir" + backup="$backup_dir/gtnh-daily-client-$(${pkgs.coreutils}/bin/date -u +%Y%m%d-%H%M%S).tar.zst" + ${pkgs.gnutar}/bin/tar --use-compress-program=${pkgs.zstd}/bin/zstd -C "$HOME/.local/share/PrismLauncher/instances" -cf "$backup" "${gtnhDailyInstanceName}" + + PATH=${pkgs.git}/bin:$PATH ${gtnhDailyUpdater}/bin/gtnh-daily-updater update \ + --instance-dir "$instance_dir" \ + --manifest-file "$local_manifest" + printf '%s\n' "$fetched_hash" > "$local_hash" + + ${pkgs.findutils}/bin/find "$backup_dir" -maxdepth 1 -type f -name 'gtnh-daily-client-*.tar.zst' -printf '%T@ %p\0' \ + | ${pkgs.coreutils}/bin/sort -z -nr \ + | ${pkgs.coreutils}/bin/tail -z -n +$(( ${toString gtnhDailyBackupRetentionCount} + 1 )) \ + | ${pkgs.gawk}/bin/awk -v RS='\0' '{ sub(/^[^ ]+ /, ""); if ($0 != "") print $0 }' \ + | ${pkgs.findutils}/bin/xargs -r -d '\n' ${pkgs.coreutils}/bin/rm -f -- + + echo "Synced GTNH Daily client to $server manifest $expected_hash" + ''; + in + { + # Desktop entries expose stable and Daily GTNH launches to graphical menus. + xdg.desktopEntries = { + gtnh = { + name = "GregTech New Horizons"; + icon = "${config.home.homeDirectory}/.local/share/PrismLauncher/instances/GT_New_Horizons_2.8.4_Java_17-25/icon.png"; + exec = "prismlauncher --launch GT_New_Horizons_2.8.4_Java_17-25"; + }; + gtnh-daily = { + name = gtnhDailyInstanceName; + icon = "${gtnhDailyInstanceDir}/icon.png"; + exec = "prismlauncher --launch \"${gtnhDailyInstanceName}\" --server localhost:25566"; + }; }; + # User packages install Prism and, on Linux, the bootstrap/sync helper commands. home.packages = [ (pkgs.prismlauncher.override { jdks = [ pkgs.jdk25 ]; }) + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + gtnhDailyClientBootstrap + gtnhDailyClientSync ]; + # User systemd timer is Linux-only; Darwin can still evaluate and install Prism. + systemd.user = lib.mkIf pkgs.stdenv.hostPlatform.isLinux { + services.gtnh-daily-client-sync = { + Unit = { + Description = "Sync GTNH Daily Prism client to the server manifest"; + After = [ "network-online.target" ]; + }; + Service = { + Type = "oneshot"; + # Run sync/backup work at lower CPU and disk priority so interactive desktop use wins. + Nice = 10; + IOSchedulingClass = "best-effort"; + IOSchedulingPriority = 7; + ExecStart = "${gtnhDailyClientSync}/bin/gtnh-daily-client-sync"; + }; + }; + timers.gtnh-daily-client-sync = { + Unit.Description = "Sync GTNH Daily Prism client on schedule"; + Timer = { + OnCalendar = "hourly"; + Persistent = true; + RandomizedDelaySec = "15m"; + }; + Install.WantedBy = [ "timers.target" ]; + }; + }; }; }; } diff --git a/nix/packages/gtnh-daily-updater.nix b/nix/packages/gtnh-daily-updater.nix new file mode 100644 index 00000000..0634ef2c --- /dev/null +++ b/nix/packages/gtnh-daily-updater.nix @@ -0,0 +1,23 @@ +{ inputs, ... }: +{ + perSystem = + { pkgs, ... }: + let + version = "0-unstable-2026-06-05"; + in + { + packages.gtnh-daily-updater = pkgs.buildGoModule { + pname = "gtnh-daily-updater"; + inherit version; + src = inputs.gtnh-daily-updater; + patches = [ ./patches/gtnh-daily-updater-manifest-file.patch ]; + vendorHash = "sha256-PXBvGE3UJTJzV1nCAQXlT3OnLBSmNxt6x7lqisdYzyU="; + nativeCheckInputs = [ pkgs.git ]; + meta = { + description = "Automated updater for GT: New Horizons daily builds"; + homepage = "https://github.com/Caedis/gtnh-daily-updater"; + mainProgram = "gtnh-daily-updater"; + }; + }; + }; +} diff --git a/nix/packages/patches/gtnh-daily-updater-manifest-file.patch b/nix/packages/patches/gtnh-daily-updater-manifest-file.patch new file mode 100644 index 00000000..b5144f0f --- /dev/null +++ b/nix/packages/patches/gtnh-daily-updater-manifest-file.patch @@ -0,0 +1,142 @@ +diff --git a/cmd/root.go b/cmd/root.go +index aa09fb4..060f4ba 100644 +--- a/cmd/root.go ++++ b/cmd/root.go +@@ -168,6 +168,7 @@ func expandFlagPaths() { + logFile = paths.ExpandTilde(logFile) + cacheDir = paths.ExpandTilde(cacheDir) + cacheDirAll = paths.ExpandTilde(cacheDirAll) ++ manifestFile = paths.ExpandTilde(manifestFile) + } + + func getGithubToken() string { +diff --git a/cmd/update.go b/cmd/update.go +index 391a6a7..39ab4c0 100644 +--- a/cmd/update.go ++++ b/cmd/update.go +@@ -9,12 +9,13 @@ import ( + ) + + var ( +- dryRun bool +- force bool +- latest bool +- concurrency int +- cacheDir string +- noCache bool ++ dryRun bool ++ force bool ++ latest bool ++ concurrency int ++ cacheDir string ++ noCache bool ++ manifestFile string + ) + + var updateCmdName = "update" +@@ -33,6 +34,7 @@ var updateCmd = &cobra.Command{ + CurseForgeKey: getCurseForgeKey(), + CacheDir: cacheDir, + NoCache: noCache, ++ ManifestFile: manifestFile, + } + + result, err := updater.Run(context.Background(), opts) +@@ -71,6 +73,7 @@ func init() { + updateCmd.Flags().IntVar(&concurrency, "concurrency", 6, "Number of concurrent downloads") + updateCmd.Flags().StringVar(&cacheDir, "cache-dir", "", "Directory for caching downloaded mods (default: OS user cache dir + /gtnh-daily-updater/mods/)") + updateCmd.Flags().BoolVar(&noCache, "no-cache", false, "Disable download caching") ++ updateCmd.Flags().StringVar(&manifestFile, "manifest-file", "", "Use a local daily manifest JSON instead of fetching the latest remote manifest") + rootCmd.AddCommand(updateCmd) + } + +diff --git a/internal/updater/run.go b/internal/updater/run.go +index ac0e7a1..ded91c9 100644 +--- a/internal/updater/run.go ++++ b/internal/updater/run.go +@@ -21,7 +21,7 @@ func Run(ctx context.Context, opts Options) (*UpdateResult, error) { + return nil, err + } + +- m, db, mode, err := resolveSharedData(ctx, state, opts.Shared) ++ m, db, mode, err := resolveSharedData(ctx, state, opts) + if err != nil { + return nil, err + } +diff --git a/internal/updater/types.go b/internal/updater/types.go +index c655e4b..310ff85 100644 +--- a/internal/updater/types.go ++++ b/internal/updater/types.go +@@ -24,6 +24,7 @@ type Options struct { + CurseForgeKey string + CacheDir string + NoCache bool ++ ManifestFile string + // Shared optionally supplies pre-fetched manifest and assets DB. + // When non-nil, Run skips those network fetches. + Shared *SharedData +diff --git a/internal/updater/workflow_steps.go b/internal/updater/workflow_steps.go +index 4361908..32f322c 100644 +--- a/internal/updater/workflow_steps.go ++++ b/internal/updater/workflow_steps.go +@@ -2,6 +2,7 @@ package updater + + import ( + "context" ++ "encoding/json" + "errors" + "fmt" + "maps" +@@ -79,6 +80,29 @@ func fetchAndLogManifest(ctx context.Context, mode string) (*manifest.DailyManif + return m, nil + } + ++func loadAndLogManifestFile(path string) (*manifest.DailyManifest, error) { ++ logging.Infof("Loading pinned manifest %s...\n", path) ++ data, err := os.ReadFile(path) ++ if err != nil { ++ return nil, fmt.Errorf("reading manifest file: %w", err) ++ } ++ ++ var m manifest.DailyManifest ++ if err := json.Unmarshal(data, &m); err != nil { ++ return nil, fmt.Errorf("parsing manifest file: %w", err) ++ } ++ ++ logging.Debugf( ++ "Verbose: loaded pinned manifest version=%s updated=%s config=%s github-mods=%d external-mods=%d\n", ++ m.Version, ++ m.LastUpdated, ++ m.Config, ++ len(m.GithubMods), ++ len(m.ExternalMods), ++ ) ++ return &m, nil ++} ++ + func fetchAndLogAssetsDB(ctx context.Context) (*assets.AssetsDB, error) { + logging.Infoln("Fetching assets database...") + db, err := assets.Fetch(ctx) +@@ -108,8 +132,21 @@ func FetchSharedData(ctx context.Context, mode string) (*SharedData, error) { + return &SharedData{Manifest: m, AssetsDB: db, Mode: parsedMode}, nil + } + +-func resolveSharedData(ctx context.Context, state *config.LocalState, shared *SharedData) (*manifest.DailyManifest, *assets.AssetsDB, string, error) { ++func resolveSharedData(ctx context.Context, state *config.LocalState, opts Options) (*manifest.DailyManifest, *assets.AssetsDB, string, error) { + mode := resolveMode(state) ++ if opts.ManifestFile != "" { ++ m, err := loadAndLogManifestFile(opts.ManifestFile) ++ if err != nil { ++ return nil, nil, "", err ++ } ++ db, err := fetchAndLogAssetsDB(ctx) ++ if err != nil { ++ return nil, nil, "", err ++ } ++ return m, db, mode, nil ++ } ++ ++ shared := opts.Shared + if shared != nil && shared.Manifest != nil && shared.AssetsDB != nil { + sharedMode, err := manifest.ParseMode(shared.Mode) + if err != nil { diff --git a/nix/profiles/development.nix b/nix/profiles/development.nix index 39b15ac4..e2bb88ad 100644 --- a/nix/profiles/development.nix +++ b/nix/profiles/development.nix @@ -1,6 +1,7 @@ { config, lib, + inputs, ... }: let @@ -78,6 +79,7 @@ in { pkgs, ... }: { home.packages = with pkgs; [ + inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.gtnh-daily-updater ddgr gh git-lfs