feat: first-boot records what a server does with no settings
`gbrgames first-boot -game <id>` (or -image for a game with no template) starts the server once like the node-agent does (same volumes, user, SteamCMD install and startup prefix) but with none of the template's settings and no restart policy, in the isolated gbr-boottest network. It watches the console until the server exits, goes quiet or hits -max, stops it gracefully (many servers write their settings on shutdown) and records: - the console with timings, plus ready and error lines; the last strong ready line before the stop becomes a readyPattern / readyExample pair - every file created; config files are copied out and parsed into settings with their defaults (INI, properties, JSON, YAML, Unreal OptionSettings=(...) lists); log files are scanned for ready lines too - files written outside the volumes, which a recreated container loses Minecraft: ready line `Done \([0-9.]+s\)! For help, type "help"` and 76 settings (69 from server.properties) found with no template input. ARK: showed its image retries nothing when SteamCMD fails with "Missing configuration" and starts a server that was never installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
40e7372dab
commit
c9d9cd6ff5
4 files changed
+912
-1
No files matched your search
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseConfigFormats(t *testing.T) {
|
||||
// Palworld's Unreal-style list
|
||||
pal := parseConfig("/server/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", "[/Script/Pal.PalGameWorldSettings]\n"+
|
||||
`OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Palworld Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`)
|
||||
want := map[string]string{"Difficulty": "None", "ExpRate": "1.000000", "ServerName": "Default Palworld Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"}
|
||||
if len(pal) != len(want) {
|
||||
t.Fatalf("palworld: got %d settings: %+v", len(pal), pal)
|
||||
}
|
||||
for _, s := range pal {
|
||||
if s.Section != "OptionSettings" || want[s.Key] != s.Value {
|
||||
t.Errorf("palworld setting %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Minecraft's server.properties
|
||||
mc := parseConfig("/data/server.properties", "#Minecraft server properties\nmax-players=20\nmotd=A Minecraft 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 Minecraft's runner 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,
|
||||
"Pal/Saved/Config/LinuxServer/x.ini": true,
|
||||
"ShooterGame/Saved/Logs/ShooterGame.log": true,
|
||||
"steamapps/appmanifest_123.acf": false,
|
||||
"Engine/Config/BaseEngine.ini": false,
|
||||
"plugins/Geyser/config.yml": false,
|
||||
"world/level.dat": false,
|
||||
} {
|
||||
if got := isConfigCandidate(rel, 1000); got != want {
|
||||
t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user