Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 7 additions & 4 deletions ark-survival-evolved/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions ark-survival-evolved/module.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions ark-survival-evolved/specs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

---

Expand Down
47 changes: 46 additions & 1 deletion ark-survival-evolved/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
117 changes: 117 additions & 0 deletions test-ark-volume-migration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""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"]
images = {}
references = [spec["image"]]
for version in spec["versions"]:
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.
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"
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 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":
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)
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__":
unittest.main()
Loading