What a builder boot sees is now what a node does: - First boots (untouched, or -as-deployed with exactly a new server's settings) are deployed by the node-agent's BootTest in keep mode; the builder only watches the console and inspects what's left. The Go copy of the deploy (its own docker run, mounts and startup prefix) is gone. It had drifted: it gave the image's VOLUME folders their own volumes, which nodes don't, so saves there looked safe while every start loses them. - Files written outside /server and /data are counted whatever their type (it only counted config-like extensions, so .sav or .db saves went unseen), including the image's VOLUME folders; logs, pid and lock files and system folders are left out. - Boot tests ask the server to stay up holdSec (60 s, test.yaml can set it) after its ready line and to listen on every published container port (needs the node-agent's matching BootTest). - Steam apps are downloaded once per build into a gbr.cache volume and copied into each boot, so later boots only check the files (STEAM_CACHE=off). - RUNNER.md no longer suggests docker system prune, which deletes the gbr-boottest network when nothing is using it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
6.0 KiB
Go
143 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParseConfigFormats(t *testing.T) {
|
|
// An Unreal-style option list
|
|
list := parseConfig("/server/Game/Saved/Config/LinuxServer/GameSettings.ini", "[/Script/Game.WorldSettings]\n"+
|
|
`OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`)
|
|
want := map[string]string{"Difficulty": "None", "ExpRate": "1.000000", "ServerName": "Default Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"}
|
|
if len(list) != len(want) {
|
|
t.Fatalf("option list: got %d settings: %+v", len(list), list)
|
|
}
|
|
for _, s := range list {
|
|
if s.Section != "OptionSettings" || want[s.Key] != s.Value {
|
|
t.Errorf("option list setting %+v", s)
|
|
}
|
|
}
|
|
|
|
// A server.properties file
|
|
mc := parseConfig("/data/server.properties", "#Server properties\nmax-players=20\nmotd=A Server\nlevel-seed=\n")
|
|
if len(mc) != 3 || mc[0].Key != "max-players" || mc[0].Value != "20" || mc[2].Value != "" {
|
|
t.Errorf("properties: %+v", mc)
|
|
}
|
|
|
|
// INI sections and comments
|
|
ini := parseConfig("/server/x.ini", "; comment\n[ServerSettings]\nRCONPort = 27020\n[Other]\nKey=\"quoted\"\n")
|
|
if len(ini) != 2 || ini[0].Section != "ServerSettings" || ini[0].Value != "27020" || ini[1].Value != "quoted" {
|
|
t.Errorf("ini: %+v", ini)
|
|
}
|
|
|
|
// JSON, one level deep
|
|
js := parseConfig("/config/settings.json", `{"name":"x","limits":{"players":8},"tags":["a"]}`)
|
|
if len(js) != 3 || js[0].Key != "players" || js[0].Section != "limits" || js[0].Value != "8" {
|
|
t.Errorf("json: %+v", js)
|
|
}
|
|
}
|
|
|
|
func TestSuggestPattern(t *testing.T) {
|
|
for line, mustMatch := range map[string]string{
|
|
`[01:32:38 INFO]: Done (24.283s)! For help, type "help"`: `[03:01:02 INFO]: Done (7.5s)! For help, type "help"`,
|
|
`[2026.10.25-03.10.36:455][ 0]Server has completed startup and is now advertising for join.`: `[2026.10.26-11.00.00:001][ 12]Server has completed startup and is now advertising for join.`,
|
|
`2026-10-25T03:10:36Z Server started on port 7777`: `2026-10-26T09:00:00Z Server started on port 8000`,
|
|
} {
|
|
p := suggestPattern(line)
|
|
if p == "" {
|
|
t.Errorf("no pattern for %q", line)
|
|
continue
|
|
}
|
|
re := regexp.MustCompile(p)
|
|
if !re.MatchString(line) || !re.MatchString(mustMatch) {
|
|
t.Errorf("pattern %q should match %q and %q", p, line, mustMatch)
|
|
}
|
|
}
|
|
if p := suggestPattern("[12:00:00] ok"); p != "" {
|
|
t.Errorf("too-short line gave pattern %q", p)
|
|
}
|
|
}
|
|
|
|
func TestAnalyzeLines(t *testing.T) {
|
|
r := &bootReport{}
|
|
analyzeLines(r, []logLine{
|
|
{At: 1, Source: "console", Text: "Starting server"},
|
|
{At: 2, Source: "console", Text: "Failed to load optional module"},
|
|
{At: 9, Source: "console", Text: "[09:00:01 INFO]: Done (8.1s)! For help, type \"help\""},
|
|
{At: 9, Source: "console", Text: "[09:00:01 INFO]: Thread RCON Listener started"},
|
|
// After the stop at 200s: the shutdown, as a wrapper script prints it
|
|
{At: 201, Source: "console", Text: "[06:01:39] [RCON Listener #1/INFO]: Thread RCON Client /0:0:0:0:0:0:0:1 started"},
|
|
{At: 202, Source: "console", Text: "2026-10-25T06:01:39.976Z\tINFO\tmc-server-runner\tDone"},
|
|
}, []logLine{{At: -1, Source: "/server/x.log", Text: "Listening on 0.0.0.0:7777"}}, 200)
|
|
if len(r.ErrorLines) != 1 {
|
|
t.Fatalf("errors %v", r.ErrorLines)
|
|
}
|
|
// The last ready line before the stop, not the shutdown's "Done"
|
|
if r.SuggestedExample != `[09:00:01 INFO]: Done (8.1s)! For help, type "help"` {
|
|
t.Errorf("example = %q", r.SuggestedExample)
|
|
}
|
|
|
|
// No console candidate: fall back to the first one in a log file
|
|
r = &bootReport{}
|
|
analyzeLines(r, []logLine{{At: 1, Source: "console", Text: "booting"}},
|
|
[]logLine{{At: -1, Source: "/server/a.log", Text: "Server has completed startup and is now advertising for join."},
|
|
{At: -1, Source: "/server/a.log", Text: "Server shutdown done"}}, 100)
|
|
if r.SuggestedExample != "Server has completed startup and is now advertising for join." {
|
|
t.Errorf("file fallback example = %q", r.SuggestedExample)
|
|
}
|
|
}
|
|
|
|
func TestConfigCandidates(t *testing.T) {
|
|
for rel, want := range map[string]bool{
|
|
"server.properties": true,
|
|
"Game/Saved/Config/LinuxServer/x.ini": true,
|
|
"Game/Saved/Logs/Server.log": true,
|
|
"steamapps/appmanifest_123.acf": false,
|
|
"Engine/Config/BaseEngine.ini": false,
|
|
"plugins/SomePlugin/config.yml": false,
|
|
"world/level.dat": false,
|
|
} {
|
|
if got := isConfigCandidate(rel, 1000); got != want {
|
|
t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOutsideFilesCountsEverySaveButNotSystemOrTransientFiles(t *testing.T) {
|
|
diff := `C /home
|
|
C /home/steam
|
|
A /home/steam/.config
|
|
A /home/steam/.config/example
|
|
A /home/steam/.config/example/worlds
|
|
A /home/steam/.config/example/worlds/world1.db
|
|
A /home/steam/.config/example/worlds/world1.fwl
|
|
A /home/steam/.config/example/Player.log
|
|
C /etc
|
|
C /etc/hostname
|
|
A /tmp/x.sock
|
|
A /server/should-not-count.sav
|
|
D /home/steam/old.txt
|
|
A /home/steam/server.pid`
|
|
got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"})
|
|
want := []string{"A /home/steam/.config/example/worlds/world1.db", "A /home/steam/.config/example/worlds/world1.fwl"}
|
|
if strings.Join(got, "|") != strings.Join(want, "|") {
|
|
t.Errorf("got %q\nwant %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestUntouchedPayloadDeploysWithoutTemplateSettings(t *testing.T) {
|
|
p := untouchedPayload(&bootSpec{Image: "example/image", Startup: "./server", SteamAppID: "1000000", MemoryMb: 4096,
|
|
User: "1000:1000", Env: map[string]string{"EULA": "TRUE"}})
|
|
if p["installMethod"] != "STEAMCMD" || p["installConfig"].(map[string]any)["appId"] != "1000000" || p["timeoutSec"] != 2400 {
|
|
t.Errorf("steam install: %v", p)
|
|
}
|
|
if p["startupCommand"] != "./server" || p["dockerNetwork"] != bootTestNetwork || p["gameConfig"].(map[string]any)["EULA"] != "TRUE" {
|
|
t.Errorf("payload: %v", p)
|
|
}
|
|
if _, ok := untouchedPayload(&bootSpec{Image: "x"})["startupCommand"]; ok {
|
|
t.Error("no startup command means the image's own entrypoint")
|
|
}
|
|
}
|