From d3e2c2c9bebcdbf34c1d23a3a26e8437ab749a51 Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 21:01:31 -0500 Subject: [PATCH 1/2] fix(ark-survival-evolved): block startup on unmigrated worlds Signed-off-by: Baylem --- .github/workflows/validate.yml | 3 + ark-survival-evolved/README.md | 11 +-- ark-survival-evolved/module.yaml | 4 +- ark-survival-evolved/specs.md | 1 + ark-survival-evolved/template.yaml | 47 +++++++++++- test-ark-volume-migration.py | 112 +++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 test-ark-volume-migration.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c580c7e..64df75b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -55,6 +55,9 @@ jobs: - name: Check Palworld frame rate against an engine response run: python3 test-palworld-performance.py + - name: Check ARK world migration in the pinned vendor image + run: python3 test-ark-volume-migration.py + - uses: actions/setup-go@v5 with: go-version: "1.26" diff --git a/ark-survival-evolved/README.md b/ark-survival-evolved/README.md index 976ebf7..3b01990 100644 --- a/ark-survival-evolved/README.md +++ b/ark-survival-evolved/README.md @@ -37,11 +37,14 @@ The image starts as root and drops to the `steam` user (UID/GID 1000) in its ent ## Upgrading from 1.x -Version 1.x ran a Gameplane-built image with the volume at `/data`. Version 2.0.0 changes three things: +Schedule downtime before adopting 2.x. Version 1.0 mounted the primary claim directly at `/serverdata/ShooterGame/Saved`. Version 1.1 mounted the whole install at `/data`. Version 2.x mounts the same claim at `/app` and reads `server/ShooterGame/Saved`. Starting 2.0.0 with either old layout can create a second world. Use 2.0.1 or later for the startup check. -- **Data layout.** The install and saves move from the volume root to `server/` inside it. Before you start an existing server on 2.0.0, stop it and move everything at the root of its data volume into a new `server/` subfolder. If you skip this, the server installs again and starts a new world. -- **Cluster data.** It moves from `ShooterGame/Saved/clusters` to the new `/cluster` volume. Copy the folder contents over if you use cluster travel. -- **Config keys.** `MAP_NAME` is now `SERVER_MAP`, and `RCON_PASSWORD` is gone because the operator manages the RCON password. Rename or remove those keys in each GameServer, or the operator rejects it as having unknown config keys. +1. Stop the GameServer and any other shard that writes these transfers. Wait for their game Pods to exit. Back up the primary PVC and any existing cluster PVC. Keep those backups until the world and transfers have been verified in game. +2. Mount the primary PVC at `/app` for maintenance. For a **1.0 Saved-only claim**, move its saved data, including `SavedArks` and `Config`, into `/app/server/ShooterGame/Saved`. For a **1.1 full-install claim**, move its install, including `ShooterGame`, `Engine` and `steamapps` if present, into `/app/server`. Preserve ownership and permissions. Verify the expected map, character profiles and tribes in the destination. If both layouts contain saves, compare them and choose the intended world before moving either. +3. Mount the cluster PVC at `/cluster`. Copy all old transfers, including hidden and nested files, from the 1.0 claim’s `clusters/` or the 1.1 install’s `ShooterGame/Saved/clusters/` to the matching paths on that PVC. Compare the source and destination contents. Archive the verified old transfers outside the live install. No `clusters/` directory should remain at `/app` or `/app/server`; the old `/app/server/ShooterGame/Saved/clusters` may contain empty directories. +4. Rename `MAP_NAME` to `SERVER_MAP` and remove `RCON_PASSWORD` from GameServer configuration. The operator manages the RCON password. Start the game, load the intended world and verify a known player or tribe and any cluster transfers before reopening it. + +If the log or Kubernetes termination message begins with `ARK storage check`, keep the game stopped and inspect the named path. Version 2.0.1 checks both mounts before invoking the vendor entrypoint. A missing mount, old install, unreadable path, or residual cluster transfer blocks startup and leaves both volumes intact. ## Sample diff --git a/ark-survival-evolved/module.yaml b/ark-survival-evolved/module.yaml index 8fe6eed..a6fc99c 100644 --- a/ark-survival-evolved/module.yaml +++ b/ark-survival-evolved/module.yaml @@ -2,10 +2,10 @@ apiVersion: gameplane.local/module/v1 name: ark-survival-evolved displayName: "ARK: Survival Evolved" -version: 2.0.0 +version: 2.0.1 game: ark-survival-evolved categories: [Survival, Open World, Multiplayer, Dinosaurs] -summary: "ARK: Survival Evolved dedicated server with Source RCON console, cluster travel support, and persistent world state." +summary: "ARK: Survival Evolved with Source RCON, cluster travel, and persistent worlds. For 1.x upgrades, stop and back up first. Move 1.0 saves to /app/server/ShooterGame/Saved or the 1.1 install to /app/server, and transfers to /cluster." homepage: https://survivetheark.com/ license: MIT gameplaneMinVersion: 0.2.0-beta.7 diff --git a/ark-survival-evolved/specs.md b/ark-survival-evolved/specs.md index 8f8e1d1..db70d43 100644 --- a/ark-survival-evolved/specs.md +++ b/ark-survival-evolved/specs.md @@ -73,6 +73,7 @@ Declared ports under `spec.ports`: - "DoExit" ``` - **Signal Handling**: The image traps `SIGTERM`/`SIGINT` and stops the server through arkmanager, which saves first. +- **1.x migration**: Before the image starts, the game container checks the primary and cluster mount points, the 1.0 Saved-only layout, the 1.1 install layout and transfer files left under `server/ShooterGame/Saved/clusters`. On failure it exits 78 with a recovery message. It hands PID 1 to the pinned vendor entrypoint after these checks pass. --- diff --git a/ark-survival-evolved/template.yaml b/ark-survival-evolved/template.yaml index f59dc04..4cee272 100644 --- a/ark-survival-evolved/template.yaml +++ b/ark-survival-evolved/template.yaml @@ -20,13 +20,16 @@ metadata: spec: displayName: "ARK: Survival Evolved" game: ark-survival-evolved - version: 2.0.0 + version: 2.0.1 categories: [Survival, Open World, Multiplayer, Dinosaurs] accentColor: "#00b2e2" description: | ARK: Survival Evolved dedicated server. Source RCON powers remote administration and server actions. The game install and world saves live under `/app/server`; cross-shard cluster transfers live in their own volume at `/cluster`. + To upgrade, stop the server and back up its PVC. Move a 1.1 install into + `/app/server`, or 1.0 saved data into `/app/server/ShooterGame/Saved`. + Move cluster transfers to `/cluster`. Old data in either layout blocks startup. image: hermsi/ark-server:latest@sha256:ce291990613e76f7019be31b96fe247188ce4957724c913c0e30097042c44bf5 @@ -36,6 +39,48 @@ spec: image: hermsi/ark-server:latest-1794213856@sha256:ce291990613e76f7019be31b96fe247188ce4957724c913c0e30097042c44bf5 default: true + # The vendor entrypoint can install a new world before an admin notices the + # 1.x world on the same claim. Inspect both mounts before running vendor code. + command: ["/bin/sh", "-ec"] + args: + - | + refuse() { + message="ARK storage check: $1. Stop the server and back up both PVCs. Move the 1.1 install to /app/server or 1.0 saves to /app/server/ShooterGame/Saved. Move old cluster transfers to /cluster. Verify copies before archiving leftovers. See the module README." + printf '%s\n' "$message" >&2 + if [ -w /dev/termination-log ]; then printf '%s\n' "$message" > /dev/termination-log || :; fi + exit 78 + } + [ "$ARK_SERVER_VOLUME" = /app ] || refuse "the vendor data path must be /app" + for mount in /app /cluster; do + [ ! -L "$mount" ] && [ -d "$mount" ] && [ -r "$mount" ] && [ -x "$mount" ] && + /usr/bin/mountpoint -q "$mount" || refuse "missing or inaccessible PVC at $mount" + done + # GNU find returns nonzero on a traversal error or when its batched + # command fails. Stop at the first match, even a dangling symlink. + no_saved_root() { + /usr/bin/find "$1" -mindepth 1 -maxdepth 1 \ + \( -name 'SavedArks*' -o -name SaveGames -o -name clusters -o -name Config -o -iname '*.ark*' \) \ + -exec /bin/false {} + -quit || refuse "1.0 save data or unreadable entries at $1" + } + no_saved_root /app + /usr/bin/find /app -mindepth 1 -maxdepth 1 \( -name ShooterGame -o -name Engine -o -name steamapps \) \ + -exec /bin/false {} + -quit || refuse "1.1 install or unreadable entries at /app" + old=/app/server/ShooterGame/Saved/clusters + parent=/app + for name in server ShooterGame Saved clusters; do + /usr/bin/find "$parent" -mindepth 1 -maxdepth 1 -name "$name" \ + \( ! -type d -o ! -readable -o ! -executable \) -exec /bin/false {} + -quit || + refuse "cannot inspect $parent/$name" + parent="$parent/$name" + [ -d "$parent" ] || break + if [ "$parent" = /app/server ]; then no_saved_root "$parent"; fi + done + if [ -d "$old" ]; then + /usr/bin/find "$old" -mindepth 1 ! -type d -exec /bin/false {} + -quit || + refuse "unmigrated transfers or unreadable entries at $old" + fi + exec /docker-entrypoint.sh + # The image starts as root: its entrypoint fixes volume ownership, loads # the crontab, then drops to the `steam` user (UID/GID 1000) with gosu. # Do NOT set runAsUser here, or the entrypoint cannot drop privileges. diff --git a/test-ark-volume-migration.py b/test-ark-volume-migration.py new file mode 100644 index 0000000..5e74bbf --- /dev/null +++ b/test-ark-volume-migration.py @@ -0,0 +1,112 @@ +"""Exercise the catalog command on disposable volumes in the pinned ARK image.""" + +import json +from pathlib import Path +import subprocess +import unittest + +import yaml + + +class ArkVolumeMigration(unittest.TestCase): + @classmethod + def setUpClass(cls): + path = Path(__file__).parent / "ark-survival-evolved/template.yaml" + spec = yaml.safe_load(path.read_text())["spec"] + cls.image = spec["image"] + cls.assert_pinned_versions(spec) + subprocess.run(["docker", "pull", "--platform", "linux/amd64", cls.image], check=True) + image = json.loads(subprocess.check_output(["docker", "image", "inspect", cls.image]))[0] + original = image["Config"] + if original["Entrypoint"] != ["/docker-entrypoint.sh"] or original.get("Cmd"): + raise AssertionError("Review the guard's handoff for the new vendor startup command") + cls.argv = (spec.get("command") or original["Entrypoint"]) + (spec.get("args") or []) + + @staticmethod + def assert_pinned_versions(spec): + digest = spec["image"].split("@sha256:")[1] + for version in spec["versions"]: + if not version["image"].endswith("@sha256:" + digest): + raise AssertionError("Every ARK variant must exercise its own vendor entrypoint") + + def test_real_vendor_handoff(self): + # This validation is the first action of the genuine pinned entrypoint. + # It establishes the handoff before SteamCMD runs or writes a game. + result = subprocess.run( + ["docker", "run", "--rm", "--pull=never", "--network=none", "--read-only", + "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/app", + "--tmpfs", "/cluster", "--env", "UPDATE_WARN_MINUTES=not-a-number", + "--entrypoint", self.argv[0], self.image, *self.argv[1:]], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 1, result.stderr + result.stdout) + self.assertIn("UPDATE_WARN_MINUTES must be a whole number", result.stdout + result.stderr) + + def test_startup(self): + world = "mkdir -p /app/ShooterGame/Saved/SavedArks; echo survivor >/app/ShooterGame/Saved/SavedArks/TheIsland.ark" + migrated = "mkdir -p /app/server/ShooterGame/Saved/SavedArks; echo survivor >/app/server/ShooterGame/Saved/SavedArks/TheIsland.ark" + old_cluster = "/app/server/ShooterGame/Saved/clusters" + cases = [ + ("fresh volume", "mkdir /app/lost+found", True), + ("migrated world and uploads", migrated + "; echo dino >/cluster/tribe", True), + ("empty old cluster folders", migrated + f"; mkdir -p {old_cluster}/empty", True), + ("legacy world", world, False), + ("1.0 Saved-only PVC", "mkdir /app/SavedArks; echo survivor >/app/SavedArks/TheIsland.ark", False), + ("1.0 transfer-only PVC", "mkdir -p /app/clusters/id; echo dino >/app/clusters/id/.upload", False), + ("1.0 layout moved one level", "mkdir -p /app/server/SavedArks; echo survivor >/app/server/SavedArks/TheIsland.ark", False), + ("partial 1.0 layout and new saves", migrated + "; mkdir /app/Config", False), + ("old and new worlds", world + "; " + migrated, False), + ("partially moved install", migrated + "; mkdir /app/Engine", False), + ("unmigrated cluster upload", migrated + f"; mkdir -p {old_cluster}/id; echo dino >{old_cluster}/id/.upload", False), + ("conflicting cluster uploads", migrated + f"; mkdir -p {old_cluster}; echo old >{old_cluster}/tribe; echo new >/cluster/tribe", False), + ("broken legacy symlink", "ln -s /missing /app/ShooterGame", False), + ("symlink in target path", "ln -s /missing /app/server", False), + ("inaccessible target", "mkdir /app/server; chmod 000 /app/server", False), + ("unreadable old transfer folder", f"mkdir -p {old_cluster}/id; chmod 000 {old_cluster}/id", False), + ("non-directory save path", "mkdir -p /app/server/ShooterGame; touch /app/server/ShooterGame/Saved", False), + ("redirected vendor storage", "export ARK_SERVER_VOLUME=/tmp", False), + ("missing cluster mount", "mkdir -p /cluster", False), + ] + # Replace only the vendor entrypoint in this throwaway container. A + # successful guard must exec it as PID 1 without downloading the game. + setup = """ + test -x /docker-entrypoint.sh + printf '%s\n' '#!/bin/sh' '[ ! -s /tmp/save-hashes ] || sha256sum -c --status /tmp/save-hashes || exit 93' 'echo "VENDOR PID=$$"' > /docker-entrypoint.sh + : > /dev/termination-log + """ + snapshot = """ + for file in /app/SavedArks/TheIsland.ark /app/clusters/id/.upload /app/server/SavedArks/TheIsland.ark \ + /app/ShooterGame/Saved/SavedArks/TheIsland.ark \ + /app/server/ShooterGame/Saved/SavedArks/TheIsland.ark \ + /app/server/ShooterGame/Saved/clusters/id/.upload \ + /app/server/ShooterGame/Saved/clusters/tribe /cluster/tribe; do + if [ -f "$file" ]; then sha256sum "$file"; fi + done > /tmp/save-hashes + """ + for name, fixture, allowed in cases: + with self.subTest(name=name): + args = ["docker", "run", "--rm", "--pull=never", "--network=none", + "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/app"] + if name != "missing cluster mount": + args += ["--tmpfs", "/cluster"] + check = """ + set +e + "$@" + status=$? + set -e + [ ! -s /tmp/save-hashes ] || sha256sum -c --status /tmp/save-hashes + test "$status" = 78 && grep -q 'ARK storage check' /dev/termination-log + """ + script = setup + "\n" + fixture + "\n" + snapshot + ('exec "$@"' if allowed else check) + result = subprocess.run(args + ["--entrypoint", "/bin/sh", self.image, "-ec", + script, "test", *self.argv], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + if allowed: + self.assertIn("VENDOR PID=1", result.stdout) + else: + self.assertNotIn("VENDOR", result.stdout) + self.assertIn("ARK storage check", result.stderr) + + +if __name__ == "__main__": + unittest.main() From 36f0b8dd98f25396c9ceda0d2717494c0f3617bb Mon Sep 17 00:00:00 2001 From: Baylem Date: Wed, 11 Nov 2026 21:13:20 -0500 Subject: [PATCH 2/2] test(ark-survival-evolved): verify each declared vendor image Signed-off-by: Baylem --- test-ark-volume-migration.py | 71 +++++++++++++++++++----------------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/test-ark-volume-migration.py b/test-ark-volume-migration.py index 5e74bbf..cc92ea4 100644 --- a/test-ark-volume-migration.py +++ b/test-ark-volume-migration.py @@ -13,34 +13,38 @@ class ArkVolumeMigration(unittest.TestCase): def setUpClass(cls): path = Path(__file__).parent / "ark-survival-evolved/template.yaml" spec = yaml.safe_load(path.read_text())["spec"] - cls.image = spec["image"] - cls.assert_pinned_versions(spec) - subprocess.run(["docker", "pull", "--platform", "linux/amd64", cls.image], check=True) - image = json.loads(subprocess.check_output(["docker", "image", "inspect", cls.image]))[0] - original = image["Config"] - if original["Entrypoint"] != ["/docker-entrypoint.sh"] or original.get("Cmd"): - raise AssertionError("Review the guard's handoff for the new vendor startup command") - cls.argv = (spec.get("command") or original["Entrypoint"]) + (spec.get("args") or []) - - @staticmethod - def assert_pinned_versions(spec): - digest = spec["image"].split("@sha256:")[1] + images = {} + references = [spec["image"]] for version in spec["versions"]: - if not version["image"].endswith("@sha256:" + digest): - raise AssertionError("Every ARK variant must exercise its own vendor entrypoint") + if version.get("image"): + references.append(version["image"]) + for reference in references: + # The default and version tag can name the same immutable image. + digest = reference.partition("@")[2] + images.setdefault(digest or reference, reference) + cls.images = list(images.values()) + cls.argv = (spec.get("command") or ["/docker-entrypoint.sh"]) + (spec.get("args") or []) + for reference in cls.images: + subprocess.run(["docker", "pull", "--platform", "linux/amd64", reference], check=True) + image = json.loads(subprocess.check_output(["docker", "image", "inspect", reference]))[0] + original = image["Config"] + if original["Entrypoint"] != ["/docker-entrypoint.sh"] or original.get("Cmd"): + raise AssertionError(f"Review the vendor startup command for {reference}") def test_real_vendor_handoff(self): # This validation is the first action of the genuine pinned entrypoint. # It establishes the handoff before SteamCMD runs or writes a game. - result = subprocess.run( - ["docker", "run", "--rm", "--pull=never", "--network=none", "--read-only", - "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/app", - "--tmpfs", "/cluster", "--env", "UPDATE_WARN_MINUTES=not-a-number", - "--entrypoint", self.argv[0], self.image, *self.argv[1:]], - capture_output=True, text=True, - ) - self.assertEqual(result.returncode, 1, result.stderr + result.stdout) - self.assertIn("UPDATE_WARN_MINUTES must be a whole number", result.stdout + result.stderr) + for image in self.images: + with self.subTest(image=image): + result = subprocess.run( + ["docker", "run", "--rm", "--pull=never", "--network=none", "--read-only", + "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/app", + "--tmpfs", "/cluster", "--env", "UPDATE_WARN_MINUTES=not-a-number", + "--entrypoint", self.argv[0], image, *self.argv[1:]], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 1, result.stderr + result.stdout) + self.assertIn("UPDATE_WARN_MINUTES must be a whole number", result.stdout + result.stderr) def test_startup(self): world = "mkdir -p /app/ShooterGame/Saved/SavedArks; echo survivor >/app/ShooterGame/Saved/SavedArks/TheIsland.ark" @@ -83,8 +87,8 @@ def test_startup(self): if [ -f "$file" ]; then sha256sum "$file"; fi done > /tmp/save-hashes """ - for name, fixture, allowed in cases: - with self.subTest(name=name): + for image in self.images: + for name, fixture, allowed in cases: args = ["docker", "run", "--rm", "--pull=never", "--network=none", "--cap-drop=ALL", "--security-opt=no-new-privileges", "--tmpfs", "/app"] if name != "missing cluster mount": @@ -98,14 +102,15 @@ def test_startup(self): test "$status" = 78 && grep -q 'ARK storage check' /dev/termination-log """ script = setup + "\n" + fixture + "\n" + snapshot + ('exec "$@"' if allowed else check) - result = subprocess.run(args + ["--entrypoint", "/bin/sh", self.image, "-ec", - script, "test", *self.argv], capture_output=True, text=True) - self.assertEqual(result.returncode, 0, result.stderr + result.stdout) - if allowed: - self.assertIn("VENDOR PID=1", result.stdout) - else: - self.assertNotIn("VENDOR", result.stdout) - self.assertIn("ARK storage check", result.stderr) + with self.subTest(name=name, image=image): + result = subprocess.run(args + ["--entrypoint", "/bin/sh", image, "-ec", + script, "test", *self.argv], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr + result.stdout) + if allowed: + self.assertIn("VENDOR PID=1", result.stdout) + else: + self.assertNotIn("VENDOR", result.stdout) + self.assertIn("ARK storage check", result.stderr) if __name__ == "__main__":