From 40e7372dab718a117b5bc1da544a60f0a09afb48 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 01:42:55 -0400 Subject: [PATCH 1/2] feat: inspect-image reads what a game's image needs `gbrgames inspect-image -game ` pulls the image and reads, without starting the game (probe runs with --network none and bypasses the entrypoint): its user, accounts, home folder owners and modes, tools (java, steamcmd, proton...), startup scripts and the variables they read. It then checks the template against those facts: - an image built around its own user (a locked home owned by another uid, or a declared USER) needs a matching containerUser - the startup command must read settings under the names the node gives them (the agent's toUpperSnakeCase), and must not read unset variables that have no default On the template from the first commit it reports both ARK bugs found by boot tests today (uid 25000; ${RCON_PORT} vs RCONPORT); the current five templates pass. Boot tests now run it first, so such templates fail in seconds instead of after a boot. -json prints the facts for tooling. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 + cmd/gbrgames/inspect.go | 506 +++++++++++++++++++++++++++++++++++ cmd/gbrgames/inspect_test.go | 129 +++++++++ cmd/gbrgames/main.go | 5 +- scripts/boot-test.sh | 7 + 5 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 cmd/gbrgames/inspect.go create mode 100644 cmd/gbrgames/inspect_test.go diff --git a/README.md b/README.md index f68b618..2ddff4d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,12 @@ go run ./cmd/gbrgames validate # what CI runs on every push go run ./cmd/gbrgames import admin-games.export.json # pull templates from GET /api/v1/admin/games ``` +`go run ./cmd/gbrgames inspect-image -game ` pulls the game's Docker image and reads what it needs +without starting the game: its user, home folder permissions, ports, tools and startup scripts. It then +checks the template against that, e.g. an image built around its own user needs `containerUser`, and a +startup command must read settings under the names the node gives them (`RCONPort` arrives as +`RCONPORT`). Boot tests run it first, so these fail in seconds. `-json` prints the facts for tooling. + Besides the schemas, `validate` checks that each folder name matches its `id`, that referenced config keys exist, that consents don't default to accepted, and that no credentials or private network addresses end up in a template. diff --git a/cmd/gbrgames/inspect.go b/cmd/gbrgames/inspect.go new file mode 100644 index 0000000..ee7b2f6 --- /dev/null +++ b/cmd/gbrgames/inspect.go @@ -0,0 +1,506 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "flag" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +// Facts about a game server image, read without starting the game: Docker metadata plus one short probe +// (no network) for users, home folder permissions, tools and startup scripts. The template checks and, +// later, the AI builder work from these instead of guessing from web search. +type imageFacts struct { + Image string `json:"image"` + User string `json:"user"` // image's USER; "" = root + Env map[string]string `json:"env"` + ExposedPorts []string `json:"exposedPorts"` + Volumes []string `json:"volumes"` + Entrypoint []string `json:"entrypoint"` + Cmd []string `json:"cmd"` + WorkingDir string `json:"workingDir"` + Users []imageUser `json:"users"` // regular accounts (uid 1000-64999) + Homes []homeDir `json:"homes"` // /home/* and /root + Tools map[string]string `json:"tools"` // e.g. java -> /usr/bin/java + Scripts map[string]string `json:"scripts"` // startup scripts, path -> content (truncated) + EnvRead []string `json:"envRead"` // variables those scripts read + ProbeError string `json:"probeError,omitempty"` + // What the node needs to run this image: set when the image is built around a user other than 1000:1000 + SuggestedContainerUser string `json:"suggestedContainerUser,omitempty"` + SuggestionReason string `json:"suggestionReason,omitempty"` +} + +type imageUser struct { + Name string `json:"name"` + UID int `json:"uid"` + GID int `json:"gid"` + Home string `json:"home"` +} + +type homeDir struct { + Path string `json:"path"` + UID int `json:"uid"` + GID int `json:"gid"` + Mode string `json:"mode"` // octal, e.g. "700" +} + +const ( + nodeUser = "1000:1000" + maxScriptBytes = 60000 + probeMarker = "@@gbr-probe@@" +) + +// Variables every shell or runtime provides; reading them says nothing about the game. +var commonEnv = map[string]bool{ + "HOME": true, "PATH": true, "USER": true, "PWD": true, "SHELL": true, "TERM": true, "HOSTNAME": true, + "LANG": true, "LC_ALL": true, "UID": true, "GID": true, "IFS": true, "OLDPWD": true, "TMPDIR": true, + "XDG_RUNTIME_DIR": true, "LD_LIBRARY_PATH": true, "DEBIAN_FRONTEND": true, "BASH_SOURCE": true, +} + +var ( + envRef = regexp.MustCompile(`\$\{?([A-Z][A-Z0-9_]{2,})\b`) + camelSplit = regexp.MustCompile(`([a-z])([A-Z]+)`) + absPath = regexp.MustCompile(`(?:^|[\s'"=;&|(])(/[A-Za-z0-9_./-]+)`) + // ${VAR:-x} / ${VAR-x}: the command copes when VAR is unset + defaultedRef = regexp.MustCompile(`\$\{([A-Z][A-Z0-9_]{2,}):?-`) + // VAR=... or export VAR=... inside the command + assignedVars = regexp.MustCompile(`(?:^|[\s;&|(])(?:export\s+)?([A-Z][A-Z0-9_]{2,})=`) +) + +func uidOf(user string) string { + uid, _, _ := strings.Cut(user, ":") + return uid +} + +// envName is the environment variable a config key reaches the container as: the node-agent's +// toUpperSnakeCase (RCONPort -> RCONPORT, ServerAdminPassword -> SERVER_ADMIN_PASSWORD). +func envName(key string) string { + return strings.ToUpper(camelSplit.ReplaceAllString(key, "${1}_${2}")) +} + +func runInspectImage(args []string) error { + fs := flag.NewFlagSet("inspect-image", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + gameID := fs.String("game", "", "also check this game's template against the image") + asJSON := fs.Bool("json", false, "print the facts as JSON") + noPull := fs.Bool("no-pull", false, "use the local image instead of pulling it") + fs.Parse(args) + + var game map[string]any + image := fs.Arg(0) + if *gameID != "" { + g, err := loadMap(filepath.Join(*gamesDir, *gameID, "game.yaml")) + if err != nil { + return err + } + game = g + if image == "" { + image, _ = g["defaultDockerImage"].(string) + } + } + if image == "" { + return errors.New("inspect-image needs an IMAGE or -game ID") + } + + if !*noPull { + if out, err := exec.Command("docker", "pull", "-q", image).CombinedOutput(); err != nil { + return fmt.Errorf("docker pull %s: %v: %s", image, err, strings.TrimSpace(string(out))) + } + } + facts, err := inspectImage(image, startupScriptPaths(game)) + if err != nil { + return err + } + + var problems, notes []string + if game != nil { + problems, notes = checkTemplate(game, facts) + } + + if *asJSON { + out := map[string]any{"facts": facts} + if game != nil { + out["problems"], out["notes"] = problems, notes + } + b, _ := json.MarshalIndent(out, "", " ") + fmt.Println(string(b)) + } else { + printFacts(facts) + if game != nil { + fmt.Printf("\nTemplate %s against this image:\n", *gameID) + for _, p := range problems { + fmt.Println(" PROBLEM " + p) + } + for _, n := range notes { + fmt.Println(" note " + n) + } + if len(problems) == 0 { + fmt.Println(" no problems found") + } + } + } + if len(problems) > 0 { + return fmt.Errorf("%d problem(s) with %s", len(problems), *gameID) + } + return nil +} + +func inspectImage(image string, extraScripts []string) (*imageFacts, error) { + out, err := exec.Command("docker", "image", "inspect", "--format", "{{json .Config}}", image).Output() + if err != nil { + return nil, fmt.Errorf("docker image inspect %s: %w", image, err) + } + var cfg struct { + User string + Env []string + ExposedPorts map[string]any + Volumes map[string]any + Entrypoint []string + Cmd []string + WorkingDir string + } + if err := json.Unmarshal(out, &cfg); err != nil { + return nil, fmt.Errorf("parse image config: %w", err) + } + f := &imageFacts{ + Image: image, User: cfg.User, Env: map[string]string{}, Entrypoint: cfg.Entrypoint, Cmd: cfg.Cmd, + WorkingDir: cfg.WorkingDir, ExposedPorts: sortedSet(cfg.ExposedPorts), Volumes: sortedSet(cfg.Volumes), + Tools: map[string]string{}, Scripts: map[string]string{}, + } + for _, kv := range cfg.Env { + if k, v, ok := strings.Cut(kv, "="); ok { + f.Env[k] = v + } + } + + scripts := append(append([]string{}, firstWord(cfg.Entrypoint), firstWord(cfg.Cmd)), extraScripts...) + probe, err := runProbe(image, scripts) + if err != nil { + f.ProbeError = err.Error() + } else { + parseProbe(f, probe) + } + suggestContainerUser(f) + return f, nil +} + +// runProbe reads users, home folders, tools and scripts from inside the image. --network none: the probe +// never needs the internet, and the image's own entrypoint is bypassed so nothing of the game starts. +func runProbe(image string, scripts []string) (string, error) { + script := ` +m="` + probeMarker + `" +echo "$m passwd"; cat /etc/passwd 2>/dev/null +echo "$m homes"; for d in /home/* /root; do [ -d "$d" ] && stat -c '%n|%u|%g|%a' "$d" 2>/dev/null; done +echo "$m tools" +for t in java steamcmd steamcmd.sh proton wine wine64 bash python3 node dotnet; do p=$(command -v "$t" 2>/dev/null) && echo "$t|$p"; done +for d in /home/*/steamcmd /opt/steamcmd /steamcmd /usr/games/steamcmd; do [ -e "$d" ] && echo "steamcmd-dir|$d"; done +for s in "$@"; do + [ -n "$s" ] || continue + p=$(command -v "$s" 2>/dev/null || echo "$s") + [ -f "$p" ] || continue + [ "$(head -c 2 "$p")" = "#!" ] || continue + echo "$m script $p"; head -c ` + strconv.Itoa(maxScriptBytes) + ` "$p"; echo +done +` + args := append([]string{"run", "--rm", "--network", "none", "--entrypoint", "sh", image, "-c", script, "probe"}, scripts...) + cmd := exec.Command("docker", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + if err := cmd.Run(); err != nil && stdout.Len() == 0 { + return "", fmt.Errorf("probe could not run a shell in the image: %v %s", err, strings.TrimSpace(stderr.String())) + } + return stdout.String(), nil +} + +func parseProbe(f *imageFacts, out string) { + section, scriptPath := "", "" + var script strings.Builder + flush := func() { + if scriptPath != "" { + f.Scripts[scriptPath] = script.String() + } + scriptPath = "" + script.Reset() + } + for _, line := range strings.Split(out, "\n") { + if rest, ok := strings.CutPrefix(line, probeMarker+" "); ok { + flush() + section = rest + if p, ok := strings.CutPrefix(rest, "script "); ok { + section, scriptPath = "script", p + } + continue + } + switch section { + case "passwd": + parts := strings.Split(line, ":") + if len(parts) < 6 { + continue + } + uid, _ := strconv.Atoi(parts[2]) + gid, _ := strconv.Atoi(parts[3]) + if uid >= 1000 && uid < 65000 { + f.Users = append(f.Users, imageUser{Name: parts[0], UID: uid, GID: gid, Home: parts[5]}) + } + case "homes": + parts := strings.Split(line, "|") + if len(parts) == 4 { + uid, _ := strconv.Atoi(parts[1]) + gid, _ := strconv.Atoi(parts[2]) + f.Homes = append(f.Homes, homeDir{Path: parts[0], UID: uid, GID: gid, Mode: parts[3]}) + } + case "tools": + if name, path, ok := strings.Cut(line, "|"); ok { + f.Tools[name] = path + } + case "script": + script.WriteString(line + "\n") + } + } + flush() + + seen := map[string]bool{} + for _, body := range f.Scripts { + for _, m := range envRef.FindAllStringSubmatch(body, -1) { + if !commonEnv[m[1]] { + seen[m[1]] = true + } + } + } + f.EnvRead = sortedKeysBool(seen) +} + +// suggestContainerUser works out whether the image only works as a specific user. Images either declare +// it (USER) or are built around an account whose home folder nobody else may enter (ARK's gameserver, +// uid 25000, with /home/gameserver at 700). +func suggestContainerUser(f *imageFacts) { + if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" { + resolved := resolveUser(f, u) + if resolved != "" && resolved != nodeUser { + f.SuggestedContainerUser = resolved + f.SuggestionReason = fmt.Sprintf("the image declares USER %s", u) + } + return + } + for _, h := range f.Homes { + if h.Path == "/root" || h.UID == 0 || strconv.Itoa(h.UID) == uidOf(nodeUser) { + continue + } + mode, err := strconv.ParseInt(h.Mode, 8, 32) + if err != nil || mode&0o005 != 0 { // others can enter it, so 1000:1000 would get in too + continue + } + // The account's own group if it has one; the folder's group otherwise + gid := h.GID + for _, acc := range f.Users { + if acc.UID == h.UID { + gid = acc.GID + } + } + f.SuggestedContainerUser = fmt.Sprintf("%d:%d", h.UID, gid) + f.SuggestionReason = fmt.Sprintf("%s belongs to uid %d with mode %s, so the node's default %s cannot enter it", h.Path, h.UID, h.Mode, nodeUser) + return + } +} + +func resolveUser(f *imageFacts, u string) string { + name, group, _ := strings.Cut(u, ":") + for _, acc := range f.Users { + if acc.Name == name || strconv.Itoa(acc.UID) == name { + if group == "" { + return fmt.Sprintf("%d:%d", acc.UID, acc.GID) + } + if _, err := strconv.Atoi(group); err == nil { + return fmt.Sprintf("%d:%s", acc.UID, group) + } + return fmt.Sprintf("%d:%d", acc.UID, acc.GID) + } + } + if _, err := strconv.Atoi(name); err == nil { + if group == "" { + group = name + } + return name + ":" + group + } + return "" +} + +// checkTemplate compares a game template with what the image needs. Problems are things that stop or +// silently break the server; notes are worth a look but often fine. +func checkTemplate(game map[string]any, f *imageFacts) (problems, notes []string) { + set, _ := game["containerUser"].(string) + effective := set + if effective == "" { + effective = nodeUser + } + // Only the uid decides whether the server can enter its folders; the group may reasonably differ + if f.SuggestedContainerUser != "" && uidOf(effective) != uidOf(f.SuggestedContainerUser) { + problems = append(problems, fmt.Sprintf("image needs containerUser %q (%s), template runs as %s", + f.SuggestedContainerUser, f.SuggestionReason, effective)) + } + if f.ProbeError != "" { + notes = append(notes, "could not look inside the image: "+f.ProbeError) + } + + // Everything the container will have in its environment: the image's own defaults plus one variable + // per config key, named the way the node-agent names them + provided := map[string]bool{} + for k := range f.Env { + provided[k] = true + } + defaults, _ := game["defaultConfig"].(map[string]any) + byLoose := map[string]string{} + for key := range defaults { + n := envName(key) + provided[n] = true + byLoose[strings.ReplaceAll(n, "_", "")] = n + } + if cmd, _ := game["defaultStartupCommand"].(string); cmd != "" { + for _, m := range assignedVars.FindAllStringSubmatch(cmd, -1) { + provided[m[1]] = true // set by the command itself, e.g. export ASA_START_PARAMS=... + } + withDefault := map[string]bool{} + for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) { + withDefault[m[1]] = true + } + for _, v := range uniqueRefs(cmd) { + if provided[v] || commonEnv[v] { + continue + } + near, misnamed := byLoose[strings.ReplaceAll(v, "_", "")] + switch { + case misnamed: + // The RCON bug: ${RCON_PORT:-27020} while the RCONPort setting arrives as RCONPORT, so the + // default always wins and the owner's setting is silently ignored + problems = append(problems, fmt.Sprintf("startup command reads ${%s}, but the setting arrives as %s", v, near)) + case !withDefault[v]: + problems = append(problems, fmt.Sprintf("startup command reads ${%s}, which nothing sets and has no default", v)) + } + } + } + + ports, _ := game["defaultPorts"].([]any) + templatePorts := map[string]bool{} + for _, p := range ports { + templatePorts[containerPort(fmt.Sprint(p))] = true + } + for _, p := range f.ExposedPorts { + if !templatePorts[p] { + notes = append(notes, fmt.Sprintf("image exposes %s, template does not publish it (fine for RCON/admin ports)", p)) + } + } + return problems, notes +} + +// containerPort turns "8211:8211/udp" or "7777/udp" or "25565" into Docker's "8211/udp" form. +func containerPort(p string) string { + if i := strings.LastIndex(p, ":"); i >= 0 { + p = p[i+1:] + } + if !strings.Contains(p, "/") { + p += "/tcp" + } + return p +} + +// startupScriptPaths picks absolute paths out of a template's startup command (e.g. ARK's +// /usr/bin/start_server) so the probe reads those scripts too. +func startupScriptPaths(game map[string]any) []string { + cmd, _ := game["defaultStartupCommand"].(string) + var paths []string + for _, m := range absPath.FindAllStringSubmatch(cmd, -1) { + if p := m[1]; !strings.HasPrefix(p, "/server") && !strings.HasPrefix(p, "/data") && !strings.HasPrefix(p, "/home/") { + paths = append(paths, p) + } + } + return paths +} + +func uniqueRefs(s string) []string { + seen := map[string]bool{} + for _, m := range envRef.FindAllStringSubmatch(s, -1) { + seen[m[1]] = true + } + return sortedKeysBool(seen) +} + +func printFacts(f *imageFacts) { + fmt.Printf("Image: %s\n", f.Image) + user := f.User + if user == "" { + user = "(root)" + } + fmt.Printf("Default user: %s\n", user) + if f.SuggestedContainerUser != "" { + fmt.Printf("Needs: containerUser %q, because %s\n", f.SuggestedContainerUser, f.SuggestionReason) + } + fmt.Printf("Entrypoint: %s\n", strings.Join(append(append([]string{}, f.Entrypoint...), f.Cmd...), " ")) + fmt.Printf("Ports: %s\n", strings.Join(f.ExposedPorts, ", ")) + fmt.Printf("Volumes: %s\n", strings.Join(f.Volumes, ", ")) + for _, u := range f.Users { + fmt.Printf("User: %s (uid %d, gid %d, home %s)\n", u.Name, u.UID, u.GID, u.Home) + } + for _, h := range f.Homes { + fmt.Printf("Home: %s owned by %d:%d mode %s\n", h.Path, h.UID, h.GID, h.Mode) + } + if len(f.Tools) > 0 { + var t []string + for _, k := range sortedKeysString(f.Tools) { + t = append(t, k+"="+f.Tools[k]) + } + fmt.Printf("Tools: %s\n", strings.Join(t, ", ")) + } + for _, p := range sortedKeysString(f.Scripts) { + fmt.Printf("Script: %s (%d bytes)\n", p, len(f.Scripts[p])) + } + if len(f.EnvRead) > 0 { + fmt.Printf("Scripts read: %s\n", strings.Join(f.EnvRead, ", ")) + } + if len(f.Env) > 0 { + fmt.Printf("Image env: %s\n", strings.Join(sortedKeysString(f.Env), ", ")) + } + if f.ProbeError != "" { + fmt.Printf("Probe: %s\n", f.ProbeError) + } +} + +func firstWord(s []string) string { + if len(s) == 0 { + return "" + } + return s[0] +} + +func sortedSet(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedKeysBool(m map[string]bool) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedKeysString(m map[string]string) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/cmd/gbrgames/inspect_test.go b/cmd/gbrgames/inspect_test.go new file mode 100644 index 0000000..8b80946 --- /dev/null +++ b/cmd/gbrgames/inspect_test.go @@ -0,0 +1,129 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func TestEnvNameMatchesNodeAgent(t *testing.T) { + for key, want := range map[string]string{ + "RCONPort": "RCONPORT", + "ServerAdminPassword": "SERVER_ADMIN_PASSWORD", + "AllowFlyerCarryPvE": "ALLOW_FLYER_CARRY_PV_E", + "bDisableStructurePlacementCollision": "B_DISABLE_STRUCTURE_PLACEMENT_COLLISION", + "RCON_PASSWORD": "RCON_PASSWORD", + "MaxPlayers": "MAX_PLAYERS", + } { + if got := envName(key); got != want { + t.Errorf("envName(%q) = %q, want %q", key, got, want) + } + } +} + +// Probe output as ARK's image produces it (trimmed) +const arkProbe = probeMarker + ` passwd +root:x:0:0:root:/root:/bin/bash +gameserver:x:25000:25000::/home/gameserver:/bin/bash +nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin +` + probeMarker + ` homes +/home/gameserver|25000|1000|700 +/root|0|0|700 +` + probeMarker + ` tools +bash|/usr/bin/bash +` + probeMarker + ` script /usr/bin/start_server +#!/bin/bash +cd /home/gameserver/steamcmd +echo "$ASA_START_PARAMS ${ENABLE_DEBUG} $HOME" +` + +func arkFacts() *imageFacts { + f := &imageFacts{Env: map[string]string{}, Tools: map[string]string{}, Scripts: map[string]string{}} + parseProbe(f, arkProbe) + suggestContainerUser(f) + return f +} + +func TestProbeParsing(t *testing.T) { + f := arkFacts() + if len(f.Users) != 1 || f.Users[0].Name != "gameserver" || f.Users[0].UID != 25000 { + t.Fatalf("users = %+v", f.Users) + } + if len(f.Homes) != 2 || f.Homes[0].Mode != "700" { + t.Fatalf("homes = %+v", f.Homes) + } + if !strings.Contains(f.Scripts["/usr/bin/start_server"], "steamcmd") { + t.Fatalf("script not captured: %q", f.Scripts) + } + if want := []string{"ASA_START_PARAMS", "ENABLE_DEBUG"}; !reflect.DeepEqual(f.EnvRead, want) { + t.Errorf("envRead = %v, want %v (HOME is common and left out)", f.EnvRead, want) + } +} + +func TestSuggestContainerUser(t *testing.T) { + // Locked home owned by another uid: use that account, with its own group + if f := arkFacts(); f.SuggestedContainerUser != "25000:25000" { + t.Errorf("ARK: suggested %q, want 25000:25000", f.SuggestedContainerUser) + } + + // uid 1000 images need nothing + f := &imageFacts{Homes: []homeDir{{Path: "/home/steam", UID: 1000, GID: 1000, Mode: "700"}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "" { + t.Errorf("uid 1000 image: suggested %q", f.SuggestedContainerUser) + } + + // A home others may enter doesn't force a user + f = &imageFacts{Homes: []homeDir{{Path: "/home/app", UID: 2000, GID: 2000, Mode: "755"}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "" { + t.Errorf("open home: suggested %q", f.SuggestedContainerUser) + } + + // A declared USER is resolved through /etc/passwd + f = &imageFacts{User: "steam", Users: []imageUser{{Name: "steam", UID: 4242, GID: 4243}}} + suggestContainerUser(f) + if f.SuggestedContainerUser != "4242:4243" { + t.Errorf("declared USER: suggested %q", f.SuggestedContainerUser) + } +} + +func TestCheckTemplate(t *testing.T) { + f := arkFacts() + game := map[string]any{ + "defaultConfig": map[string]any{"RCONPort": 27020, "MaxPlayers": 70}, + // Mirrors ARK's old command: ASA_START_PARAMS is assigned, SERVER_MAP has a default, RCON_PORT is the + // misnamed one, MAX_PLAYERS is set by a setting, MISSING has no default at all + "defaultStartupCommand": `export ASA_START_PARAMS="${ASA_START_PARAMS:-${SERVER_MAP:-TheIsland}?RCONPort=${RCON_PORT:-27020} -Max=${MAX_PLAYERS:-70} ${MISSING}}"`, + } + problems, _ := checkTemplate(game, f) + joined := strings.Join(problems, "\n") + for _, want := range []string{"containerUser \"25000:25000\"", "${RCON_PORT}, but the setting arrives as RCONPORT", "${MISSING}"} { + if !strings.Contains(joined, want) { + t.Errorf("missing problem %q in:\n%s", want, joined) + } + } + for _, unwanted := range []string{"ASA_START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} { + if strings.Contains(joined, unwanted) { + t.Errorf("false problem about %s in:\n%s", unwanted, joined) + } + } + if len(problems) != 3 { + t.Errorf("got %d problems, want 3:\n%s", len(problems), joined) + } + + // With the fixes, only the deliberately missing variable is left; 25000:1000 counts (same uid) + game["containerUser"] = "25000:1000" + game["defaultStartupCommand"] = `export ASA_START_PARAMS="${ASA_START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"` + if problems, _ := checkTemplate(game, f); len(problems) != 0 { + t.Errorf("fixed template still has problems: %v", problems) + } +} + +func TestContainerPort(t *testing.T) { + for in, want := range map[string]string{"8211:8211/udp": "8211/udp", "7777/udp": "7777/udp", "25565": "25565/tcp"} { + if got := containerPort(in); got != want { + t.Errorf("containerPort(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 3505b7c..829d15a 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -4,6 +4,7 @@ // go run ./cmd/gbrgames import export.json # write games// from GET /api/v1/admin/games // go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel // go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs +// go run ./cmd/gbrgames inspect-image -game ID # read the image's facts and check the template against them package main import ( @@ -25,6 +26,8 @@ func main() { err = runExport(os.Args[2:]) case "boottest-payload": err = runBootTestPayload(os.Args[2:]) + case "inspect-image": + err = runInspectImage(os.Args[2:]) default: usage() } @@ -35,6 +38,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE]") os.Exit(2) } diff --git a/scripts/boot-test.sh b/scripts/boot-test.sh index 0d50956..a77ed01 100755 --- a/scripts/boot-test.sh +++ b/scripts/boot-test.sh @@ -23,6 +23,13 @@ go build -o "$gbrgames" ./cmd/gbrgames || exit 1 passed=() failed=() skipped=() for id in "$@"; do echo "::group::boot test: $id" + # Seconds instead of a full boot: catches templates that can't work with their image (wrong + # container user, startup command reading a variable under the wrong name) + if ! "$gbrgames" inspect-image -game "$id"; then + failed+=("$id") + echo "::endgroup::" + continue + fi payload=$("$gbrgames" boottest-payload "$id") code=$? if [ $code -eq 3 ]; then From c9d9cd6ff582b5e9929890ed182af116cd4c5fd8 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 02:18:21 -0400 Subject: [PATCH 2/2] feat: first-boot records what a server does with no settings `gbrgames first-boot -game ` (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 --- .gitignore | 3 + cmd/gbrgames/firstboot.go | 800 +++++++++++++++++++++++++++++++++ cmd/gbrgames/firstboot_test.go | 105 +++++ cmd/gbrgames/main.go | 5 +- 4 files changed, 912 insertions(+), 1 deletion(-) create mode 100644 cmd/gbrgames/firstboot.go create mode 100644 cmd/gbrgames/firstboot_test.go diff --git a/.gitignore b/.gitignore index c7a0bfc..0142f75 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ export.json /gbrgames /gbrgames.exe + +# first-boot reports (logs and copied server files) +firstboot-out/ diff --git a/cmd/gbrgames/firstboot.go b/cmd/gbrgames/firstboot.go new file mode 100644 index 0000000..3b1342e --- /dev/null +++ b/cmd/gbrgames/firstboot.go @@ -0,0 +1,800 @@ +package main + +import ( + "archive/tar" + "bufio" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// first-boot starts a game server once with none of a template's settings applied, and records what it +// does on its own: its output (to find the "ready" and error lines) and the files it creates (to learn +// the real config file, format and defaults). It mirrors how the node-agent runs servers: same volumes, +// user, SteamCMD install and startup prefix, but without a restart policy, so a crash shows as a crash. + +// Same prefix the node-agent puts in front of every startup command (DockerService) +const agentStartupPrefix = "mkdir -p ~/.steam/sdk64 /server/.steam/sdk64 2>/dev/null; " + + "[ -f /home/steam/steamcmd/linux64/steamclient.so ] && cp -f /home/steam/steamcmd/linux64/steamclient.so ~/.steam/sdk64/ 2>/dev/null; " + + "[ -f /server/linux64/steamclient.so ] && cp -f /server/linux64/steamclient.so ~/.steam/sdk64/ 2>/dev/null; cd /server && " + +var ( + ansi = regexp.MustCompile(`\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\a]*\a`) + readyWords = regexp.MustCompile(`(?i)\b(ready|started|listening|done|advertis\w*|online|accepting connections|initiali[sz]ed|server is running|now running|startup complete|full startup)\b`) + // Words that mean the whole server is up, not just one part of it ("RCON Listener started") + strongReady = regexp.MustCompile(`(?i)\b(ready|done|advertis\w*|accepting connections|server is running|startup complete|full startup|server started)\b`) + errorWords = regexp.MustCompile(`(?i)\b(error|exception|fatal|failed|permission denied|segmentation fault|could not|unable to|cannot)\b`) + // Leading timestamps and [bracketed] prefixes that change on every run + linePrefix = regexp.MustCompile(`^\s*(?:(?:\[[^\]]*\]|\d{4}-\d\d-\d\d[T ][0-9:.,]+Z?|\d\d:\d\d:\d\d(?:[.,]\d+)?)[\s:|-]*)+`) + numberRun = regexp.MustCompile(`[0-9]+(?:\\\.[0-9]+)*`) +) + +// Files worth copying out: what a server writes as its settings, plus its own log files +var configExt = map[string]bool{".ini": true, ".cfg": true, ".conf": true, ".json": true, ".properties": true, + ".yaml": true, ".yml": true, ".toml": true, ".xml": true, ".txt": true, ".log": true} + +// Game data, engine files and caches: never settings +var skipDirs = []string{"steamapps/", "Binaries/", "Content/", "Engine/", "linux64/", "linux32/", ".steam/", "Steam/", + "compatdata/", "libraries/", "cache/", "steamcmd/", "versions/", "plugins/", "mods/", "jre/", "java/"} + +type bootSpec struct { + Name, Image, User, Startup, SteamAppID string + MemoryMb int + Env map[string]string +} + +type logLine struct { + At float64 `json:"at"` // seconds after the container started + Source string `json:"source"` // "console" or a file path + Text string `json:"text"` +} + +type createdFile struct { + Path string `json:"path"` + Size int64 `json:"size"` +} + +type setting struct { + File string `json:"file"` + Section string `json:"section,omitempty"` + Key string `json:"key"` + Value string `json:"value"` +} + +type bootReport struct { + Image string `json:"image"` + User string `json:"user"` + Outcome string `json:"outcome"` + ExitCode *int `json:"exitCode,omitempty"` + RanSeconds float64 `json:"ranSeconds"` + LastOutputAt float64 `json:"lastOutputAt"` + ConsoleLines int `json:"consoleLines"` + ReadyCandidates []logLine `json:"readyCandidates"` + ErrorLines []logLine `json:"errorLines"` + SuggestedReady string `json:"suggestedReadyPattern,omitempty"` + SuggestedExample string `json:"suggestedReadyExample,omitempty"` + FilesCreated []createdFile `json:"filesCreated"` + ConfigFiles []string `json:"configFiles"` + Settings []setting `json:"settings"` + ContainerChanges []string `json:"containerChanges"` +} + +func runFirstBoot(args []string) error { + fs := flag.NewFlagSet("first-boot", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + gameID := fs.String("game", "", "take image, install, startup and user from this game's template (not its settings)") + image := fs.String("image", "", "image to boot when there is no template yet") + user := fs.String("user", "", "uid:gid to run as (default: template containerUser, else what the image needs, else 1000:1000)") + startup := fs.String("startup", "", "startup command (default: the image's own entrypoint)") + steamApp := fs.String("steam-app", "", "install this SteamCMD app into /server first") + memory := fs.Int("memory", 0, "memory limit in MB (default: template requiredMemoryMb, else 4096)") + maxRun := fs.Duration("max", 20*time.Minute, "stop after this long even if the server keeps logging") + quiet := fs.Duration("quiet", 2*time.Minute, "stop once the server has printed nothing for this long") + outDir := fs.String("out", "", "where to write the report (default firstboot-out/)") + keep := fs.Bool("keep", false, "keep the container and volumes for a closer look") + var envFlags multiFlag + fs.Var(&envFlags, "env", "KEY=VALUE for the container (repeatable), e.g. EULA=TRUE") + fs.Parse(args) + + spec, err := buildBootSpec(*gamesDir, *gameID, *image, *startup, *steamApp, *memory, envFlags) + if err != nil { + return err + } + if out, err := exec.Command("docker", "network", "inspect", bootTestNetwork).CombinedOutput(); err != nil { + return fmt.Errorf("docker network %s is missing; first-boot only runs isolated (see RUNNER.md): %s", bootTestNetwork, strings.TrimSpace(string(out))) + } + if out, err := exec.Command("docker", "pull", "-q", spec.Image).CombinedOutput(); err != nil { + return fmt.Errorf("docker pull %s: %v: %s", spec.Image, err, strings.TrimSpace(string(out))) + } + facts, err := inspectImage(spec.Image, nil) + if err != nil { + return err + } + switch { + case *user != "": + spec.User = *user + case spec.User == "" && facts.SuggestedContainerUser != "": + spec.User = facts.SuggestedContainerUser + fmt.Printf("[first-boot] running as %s: %s\n", spec.User, facts.SuggestionReason) + case spec.User == "": + spec.User = nodeUser + } + if *outDir == "" { + *outDir = filepath.Join("firstboot-out", spec.Name) + } + if err := os.MkdirAll(filepath.Join(*outDir, "files"), 0o755); err != nil { + return err + } + + b := &booter{spec: spec, facts: facts, container: "firstboot-" + spec.Name, outDir: *outDir} + if !*keep { + defer b.cleanup() + } + report, err := b.run(*maxRun, *quiet) + if err != nil { + return err + } + if err := writeBootReport(*outDir, report, b.console()); err != nil { + return err + } + printBootReport(report, *outDir) + return nil +} + +func buildBootSpec(gamesDir, gameID, image, startup, steamApp string, memory int, envFlags []string) (*bootSpec, error) { + spec := &bootSpec{Image: image, Startup: startup, SteamAppID: steamApp, MemoryMb: memory, Env: map[string]string{}} + if gameID != "" { + g, err := loadMap(filepath.Join(gamesDir, gameID, "game.yaml")) + if err != nil { + return nil, err + } + spec.Name = gameID + if spec.Image == "" { + spec.Image, _ = g["defaultDockerImage"].(string) + } + if spec.Startup == "" { + spec.Startup, _ = g["defaultStartupCommand"].(string) + } + spec.User, _ = g["containerUser"].(string) + switch g["installMethod"] { + case "STEAMCMD": + if ic, ok := g["installConfig"].(map[string]any); ok && spec.SteamAppID == "" { + spec.SteamAppID = fmt.Sprint(ic["appId"]) + } + case "BASH_SCRIPT": + return nil, errors.New("first-boot does not run BASH_SCRIPT installs yet") + } + if spec.MemoryMb == 0 { + if n, ok := g["requiredMemoryMb"].(json.Number); ok { + m, _ := n.Int64() + spec.MemoryMb = int(m) + } + } + // Only what the server can't start without (test.yaml): EULAs, required IDs. Not the template's + // settings, so the server writes its own defaults. + if test, err := loadMap(filepath.Join(gamesDir, gameID, "test.yaml")); err == nil { + if cfg, ok := test["config"].(map[string]any); ok { + for k, v := range cfg { + spec.Env[envName(k)] = resolvePlaceholders(fmt.Sprint(v)) + } + } + if secrets, ok := test["secrets"].(map[string]any); ok { + for k, name := range secrets { + val, ok := os.LookupEnv(fmt.Sprint(name)) + if !ok || val == "" { + return nil, fmt.Errorf("%s needs secret %s for %s; set it in the environment", gameID, name, k) + } + spec.Env[envName(k)] = val + } + } + } + } + if spec.Image == "" { + return nil, errors.New("first-boot needs -game ID or -image IMAGE") + } + if spec.Name == "" { + spec.Name = strings.Trim(regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(strings.ToLower(spec.Image), "-"), "-") + } + if spec.MemoryMb == 0 { + spec.MemoryMb = 4096 + } + for _, kv := range envFlags { + k, v, ok := strings.Cut(kv, "=") + if !ok { + return nil, fmt.Errorf("-env %q: want KEY=VALUE", kv) + } + spec.Env[k] = v + } + return spec, nil +} + +type booter struct { + spec *bootSpec + facts *imageFacts + container string + outDir string + mounts map[string]string // mount path -> volume name + + mu sync.Mutex + lines []logLine + lastAt time.Time + started time.Time +} + +func (b *booter) run(maxRun, quiet time.Duration) (*bootReport, error) { + exec.Command("docker", "rm", "-f", b.container).Run() // leftovers from an interrupted run + + // Same layout as the node: one volume at /server and /data, plus a fresh one per declared VOLUME + data := b.container + "-data" + b.mounts = map[string]string{"/server": data, "/data": data} + for i, v := range b.facts.Volumes { + if _, ok := b.mounts[v]; !ok { + b.mounts[v] = fmt.Sprintf("%s-v%d", b.container, i) + } + } + for _, vol := range uniqueValues(b.mounts) { + if err := docker("volume", "create", vol); err != nil { + return nil, err + } + if err := docker("run", "--rm", "-v", vol+":/v", "alpine", "chown", "-R", b.spec.User, "/v"); err != nil { + return nil, err + } + } + + if b.spec.SteamAppID != "" { + fmt.Printf("[first-boot] installing Steam app %s\n", b.spec.SteamAppID) + // Like the node-agent: steamcmd's image runs as uid 1000, then the volume goes to the game's user + steam := "/home/steam/steamcmd/steamcmd.sh +login anonymous +quit && /home/steam/steamcmd/steamcmd.sh +force_install_dir /server +login anonymous +app_update " + + b.spec.SteamAppID + " validate +quit" + if err := docker("run", "--rm", "-v", data+":/server", "alpine", "chown", "-R", nodeUser, "/server"); err != nil { + return nil, err + } + cmd := exec.Command("docker", "run", "--rm", "--network", bootTestNetwork, "-v", data+":/server", "cm2network/steamcmd", "bash", "-c", steam) + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("steamcmd install of %s failed: %w", b.spec.SteamAppID, err) + } + if err := docker("run", "--rm", "-v", data+":/server", "alpine", "chown", "-R", b.spec.User, "/server"); err != nil { + return nil, err + } + } + + runArgs := []string{"run", "-d", "-t", "--init", "--name", b.container, "--user", b.spec.User, + "--security-opt", "no-new-privileges:true", "--pids-limit", "1024", "--network", bootTestNetwork, + "-m", fmt.Sprintf("%dm", b.spec.MemoryMb), "--memory-swap", fmt.Sprintf("%dm", b.spec.MemoryMb)} + for mount, vol := range b.mounts { + runArgs = append(runArgs, "-v", vol+":"+mount) + } + for _, k := range sortedKeysString(b.spec.Env) { + runArgs = append(runArgs, "-e", k+"="+b.spec.Env[k]) + } + runArgs = append(runArgs, b.spec.Image) + if b.spec.Startup != "" { + runArgs = append(runArgs, "bash", "-c", agentStartupPrefix+b.spec.Startup) + } + fmt.Printf("[first-boot] starting %s as %s (memory %d MB)\n", b.spec.Image, b.spec.User, b.spec.MemoryMb) + if out, err := exec.Command("docker", runArgs...).CombinedOutput(); err != nil { + return nil, fmt.Errorf("docker run: %v: %s", err, strings.TrimSpace(string(out))) + } + b.started, b.lastAt = time.Now(), time.Now() + + logs := exec.Command("docker", "logs", "-f", b.container) + pr, pw := io.Pipe() + logs.Stdout, logs.Stderr = pw, pw + if err := logs.Start(); err != nil { + return nil, err + } + done := make(chan struct{}) + go func() { + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 1024*1024), 1024*1024) + for sc.Scan() { + b.addLine("console", sc.Text()) + } + close(done) + }() + + report := &bootReport{Image: b.spec.Image, User: b.spec.User} + minRun := time.Minute + for { + time.Sleep(2 * time.Second) + elapsed := time.Since(b.started) + running := inspectField(b.container, "{{.State.Running}}") == "true" + b.mu.Lock() + silent, count := time.Since(b.lastAt), len(b.lines) + b.mu.Unlock() + switch { + case !running: + code, _ := strconv.Atoi(inspectField(b.container, "{{.State.ExitCode}}")) + report.ExitCode = &code + report.Outcome = fmt.Sprintf("exited by itself with code %d after %.0fs", code, elapsed.Seconds()) + if inspectField(b.container, "{{.State.OOMKilled}}") == "true" { + report.Outcome += fmt.Sprintf(" (killed: over the %d MB memory limit)", b.spec.MemoryMb) + } + case elapsed >= minRun && count > 0 && silent >= quiet: + report.Outcome = fmt.Sprintf("running and quiet for %.0fs after %.0fs; stopped gracefully", silent.Seconds(), elapsed.Seconds()) + case elapsed >= maxRun: + report.Outcome = fmt.Sprintf("still running and logging after %.0fs (the -max limit); stopped gracefully", elapsed.Seconds()) + default: + continue + } + break + } + report.RanSeconds = time.Since(b.started).Seconds() + // Many servers only write their settings when they shut down + exec.Command("docker", "stop", "-t", "60", b.container).Run() + pw.Close() + <-done + + b.mu.Lock() + report.ConsoleLines = len(b.lines) + if n := len(b.lines); n > 0 { + report.LastOutputAt = b.lines[n-1].At + } + console := append([]logLine{}, b.lines...) + b.mu.Unlock() + + if err := b.collectFiles(report); err != nil { + return nil, err + } + report.ContainerChanges = b.containerChanges() + analyzeLines(report, console, b.fileLines(report), report.RanSeconds) + return report, nil +} + +func (b *booter) addLine(source, raw string) { + text := strings.TrimRight(ansi.ReplaceAllString(raw, ""), "\r \t") + if text == "" { + return + } + b.mu.Lock() + b.lines = append(b.lines, logLine{At: time.Since(b.started).Round(100 * time.Millisecond).Seconds(), Source: source, Text: text}) + b.lastAt = time.Now() + b.mu.Unlock() +} + +func (b *booter) console() []logLine { + b.mu.Lock() + defer b.mu.Unlock() + return append([]logLine{}, b.lines...) +} + +// collectFiles lists everything the server left in its volumes and copies out the config and log files. +func (b *booter) collectFiles(report *bootReport) error { + for _, vol := range uniqueValues(b.mounts) { + mount := mountFor(b.mounts, vol) + out, err := exec.Command("docker", "run", "--rm", "-v", vol+":/v:ro", "alpine", "find", "/v", "-type", "f", "-exec", "stat", "-c", "%s|%n", "{}", "+").Output() + if err != nil { + return fmt.Errorf("listing %s: %w", mount, err) + } + var wanted []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + sizeStr, p, ok := strings.Cut(line, "|") + if !ok { + continue + } + size, _ := strconv.ParseInt(sizeStr, 10, 64) + rel := strings.TrimPrefix(p, "/v/") + report.FilesCreated = append(report.FilesCreated, createdFile{Path: path.Join(mount, rel), Size: size}) + if isConfigCandidate(rel, size) { + wanted = append(wanted, rel) + } + } + if len(wanted) > 150 { + wanted = wanted[:150] + } + if len(wanted) == 0 { + continue + } + if err := b.copyOut(vol, mount, wanted, report); err != nil { + return err + } + } + sort.Slice(report.FilesCreated, func(i, j int) bool { return report.FilesCreated[i].Path < report.FilesCreated[j].Path }) + return nil +} + +func isConfigCandidate(rel string, size int64) bool { + ext := strings.ToLower(path.Ext(rel)) + if !configExt[ext] { + return false + } + for _, d := range skipDirs { + if strings.HasPrefix(rel, d) || strings.Contains(rel, "/"+d) { + return false + } + } + switch ext { + case ".log": + return size <= 5<<20 + case ".txt": + return size <= 16<<10 + } + return size <= 256<<10 +} + +func (b *booter) copyOut(vol, mount string, files []string, report *bootReport) error { + args := append([]string{"run", "--rm", "-v", vol + ":/v:ro", "alpine", "tar", "cf", "-", "-C", "/v"}, files...) + cmd := exec.Command("docker", args...) + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + if err := cmd.Start(); err != nil { + return err + } + tr := tar.NewReader(stdout) + for { + h, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + if h.Typeflag != tar.TypeReg { + continue + } + content, err := io.ReadAll(tr) + if err != nil { + return err + } + full := path.Join(mount, h.Name) + dest := filepath.Join(b.outDir, "files", filepath.FromSlash(strings.TrimPrefix(full, "/"))) + os.MkdirAll(filepath.Dir(dest), 0o755) + if err := os.WriteFile(dest, content, 0o644); err != nil { + return err + } + if strings.HasSuffix(full, ".log") { + continue + } + report.ConfigFiles = append(report.ConfigFiles, full) + report.Settings = append(report.Settings, parseConfig(full, string(content))...) + } + return cmd.Wait() +} + +// fileLines reads the copied log files, so ready and error lines that never reach the console (ARK +// writes to ShooterGame.log) are found too. +func (b *booter) fileLines(report *bootReport) []logLine { + var out []logLine + for _, f := range report.FilesCreated { + if !strings.HasSuffix(f.Path, ".log") { + continue + } + raw, err := os.ReadFile(filepath.Join(b.outDir, "files", filepath.FromSlash(strings.TrimPrefix(f.Path, "/")))) + if err != nil { + continue + } + for _, l := range strings.Split(string(raw), "\n") { + if t := strings.TrimSpace(ansi.ReplaceAllString(l, "")); t != "" { + out = append(out, logLine{At: -1, Source: f.Path, Text: t}) + } + } + } + return out +} + +// containerChanges lists files the server wrote outside its volumes: settings there are lost when the +// node recreates the container, which a template needs to know about. +func (b *booter) containerChanges() []string { + out, err := exec.Command("docker", "diff", b.container).Output() + if err != nil { + return nil + } + var changes []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + kind, p, ok := strings.Cut(line, " ") + if !ok || kind == "D" { + continue + } + skip := false + for _, prefix := range []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/var/log", "/var/cache", "/var/tmp"} { + if p == prefix || strings.HasPrefix(p, prefix+"/") { + skip = true + } + } + for mount := range b.mounts { + if p == mount || strings.HasPrefix(p, mount+"/") { + skip = true + } + } + if !skip && configExt[strings.ToLower(path.Ext(p))] { + changes = append(changes, kind+" "+p) + } + } + return changes +} + +func (b *booter) cleanup() { + exec.Command("docker", "rm", "-f", b.container).Run() + for _, vol := range uniqueValues(b.mounts) { + exec.Command("docker", "volume", "rm", vol).Run() + } +} + +// analyzeLines picks out ready-looking and error lines and turns the last ready line into a readyPattern +// suggestion (plus the line itself as readyExample, which validation checks the pattern against). +// Console lines printed after stopAt are the server shutting down ("Done", "RCON client started" from +// the stop command), so they never count as ready. +func analyzeLines(report *bootReport, console, files []logLine, stopAt float64) { + var running []logLine + for _, l := range console { + if l.At < stopAt { + running = append(running, l) + } + } + for _, l := range append(append([]logLine{}, console...), files...) { + switch { + case errorWords.MatchString(l.Text): + report.ErrorLines = append(report.ErrorLines, l) + case readyWords.MatchString(l.Text): + report.ReadyCandidates = append(report.ReadyCandidates, l) + } + } + report.ErrorLines = lastN(report.ErrorLines, 25) + report.ReadyCandidates = lastN(report.ReadyCandidates, 15) + + // The last ready-looking console line before the stop; log files (no timestamps of ours, and they + // contain the shutdown too) only when the console has none + var pick *logLine + for _, words := range []*regexp.Regexp{strongReady, readyWords} { + for i := range running { + if !errorWords.MatchString(running[i].Text) && words.MatchString(running[i].Text) { + pick = &running[i] + } + } + if pick != nil { + break + } + for i := range files { + if !errorWords.MatchString(files[i].Text) && words.MatchString(files[i].Text) { + pick = &files[i] + break // the first one in a file is the startup, later ones may be the shutdown + } + } + if pick != nil { + break + } + } + if pick != nil { + if pattern := suggestPattern(pick.Text); pattern != "" { + report.SuggestedReady, report.SuggestedExample = pattern, pick.Text + } + } +} + +// suggestPattern turns a concrete log line into a regex that matches it on every run: timestamps and +// [prefixes] dropped, numbers generalized. +func suggestPattern(line string) string { + core := strings.TrimSpace(linePrefix.ReplaceAllString(line, "")) + if len(core) < 4 { + return "" + } + if len(core) > 80 { + core = core[:80] + if i := strings.LastIndex(core, " "); i > 20 { + core = core[:i] + } + } + pattern := numberRun.ReplaceAllString(regexp.QuoteMeta(core), `[0-9.]+`) + if re, err := regexp.Compile(pattern); err != nil || !re.MatchString(line) { + return "" + } + return pattern +} + +// parseConfig reads settings out of a config file: JSON objects, INI/properties-style key=value lines +// (including Unreal's OptionSettings=(A=1,B="x") lists) and top-level YAML keys. +func parseConfig(file, content string) []setting { + var out []setting + switch strings.ToLower(path.Ext(file)) { + case ".json": + var obj map[string]any + if json.Unmarshal([]byte(content), &obj) != nil { + return nil + } + for _, k := range sortedKeysAny(obj) { + if sub, ok := obj[k].(map[string]any); ok { + for _, sk := range sortedKeysAny(sub) { + out = append(out, setting{File: file, Section: k, Key: sk, Value: jsonValue(sub[sk])}) + } + continue + } + out = append(out, setting{File: file, Key: k, Value: jsonValue(obj[k])}) + } + return out + case ".yaml", ".yml": + for _, line := range strings.Split(content, "\n") { + if line == "" || line[0] == ' ' || line[0] == '#' || line[0] == '-' { + continue + } + if k, v, ok := strings.Cut(line, ":"); ok { + out = append(out, setting{File: file, Key: strings.TrimSpace(k), Value: strings.Trim(strings.TrimSpace(v), `"'`)}) + } + } + return out + case ".xml": + return nil // listed and copied; structure varies too much to flatten + } + section := "" + for _, raw := range strings.Split(content, "\n") { + line := strings.TrimSpace(strings.TrimPrefix(raw, "\ufeff")) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "//") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.Trim(line, "[]") + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok { + continue + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if strings.HasPrefix(v, "(") && strings.HasSuffix(v, ")") { + for _, item := range splitTopLevel(v[1 : len(v)-1]) { + if ik, iv, ok := strings.Cut(item, "="); ok { + out = append(out, setting{File: file, Section: k, Key: strings.TrimSpace(ik), Value: strings.Trim(strings.TrimSpace(iv), `"`)}) + } + } + continue + } + out = append(out, setting{File: file, Section: section, Key: k, Value: strings.Trim(v, `"`)}) + } + return out +} + +// splitTopLevel splits on commas that are not inside quotes or parentheses. +func splitTopLevel(s string) []string { + var parts []string + depth, quoted, start := 0, false, 0 + for i, c := range s { + switch { + case c == '"': + quoted = !quoted + case quoted: + case c == '(': + depth++ + case c == ')': + depth-- + case c == ',' && depth == 0: + parts = append(parts, s[start:i]) + start = i + 1 + } + } + return append(parts, s[start:]) +} + +func writeBootReport(dir string, report *bootReport, console []logLine) error { + var log strings.Builder + for _, l := range console { + fmt.Fprintf(&log, "[%7.1fs] %s\n", l.At, l.Text) + } + if err := os.WriteFile(filepath.Join(dir, "console.log"), []byte(log.String()), 0o644); err != nil { + return err + } + b, _ := json.MarshalIndent(report, "", " ") + return os.WriteFile(filepath.Join(dir, "report.json"), b, 0o644) +} + +func printBootReport(r *bootReport, dir string) { + fmt.Printf("\n[first-boot] %s as %s: %s\n", r.Image, r.User, r.Outcome) + fmt.Printf(" console: %d lines, last one at %.0fs\n", r.ConsoleLines, r.LastOutputAt) + if r.SuggestedReady != "" { + fmt.Printf(" readyPattern suggestion: %s\n readyExample: %s\n", r.SuggestedReady, r.SuggestedExample) + } + for _, l := range lastN(r.ReadyCandidates, 5) { + fmt.Printf(" ready? %s\n", describeLine(l)) + } + for _, l := range lastN(r.ErrorLines, 5) { + fmt.Printf(" error? %s\n", describeLine(l)) + } + fmt.Printf(" files created: %d; config files read: %d; settings found: %d\n", len(r.FilesCreated), len(r.ConfigFiles), len(r.Settings)) + for _, f := range r.ConfigFiles { + n := 0 + for _, s := range r.Settings { + if s.File == f { + n++ + } + } + fmt.Printf(" %s (%d settings)\n", f, n) + } + if len(r.ContainerChanges) > 0 { + fmt.Printf(" written outside the volumes (lost when the node recreates the container): %s\n", strings.Join(r.ContainerChanges, ", ")) + } + fmt.Printf(" full report: %s\n", filepath.Join(dir, "report.json")) +} + +func describeLine(l logLine) string { + where := l.Source + if l.At >= 0 { + where = fmt.Sprintf("%.0fs", l.At) + } + text := l.Text + if len(text) > 140 { + text = text[:140] + "..." + } + return fmt.Sprintf("[%s] %s", where, text) +} + +func docker(args ...string) error { + if out, err := exec.Command("docker", args...).CombinedOutput(); err != nil { + return fmt.Errorf("docker %s: %v: %s", args[0], err, strings.TrimSpace(string(out))) + } + return nil +} + +func inspectField(container, format string) string { + out, _ := exec.Command("docker", "inspect", "-f", format, container).Output() + return strings.TrimSpace(string(out)) +} + +func uniqueValues(m map[string]string) []string { + seen := map[string]bool{} + var out []string + for _, v := range m { + if !seen[v] { + seen[v] = true + out = append(out, v) + } + } + sort.Strings(out) + return out +} + +// mountFor names a volume by its first mount path (the data volume is both /server and /data). +func mountFor(mounts map[string]string, vol string) string { + var paths []string + for p, v := range mounts { + if v == vol { + paths = append(paths, p) + } + } + sort.Strings(paths) + if len(paths) > 1 && paths[0] == "/data" { + return "/server" + } + return paths[0] +} + +func lastN[T any](s []T, n int) []T { + if len(s) > n { + return s[len(s)-n:] + } + return s +} + +func jsonValue(v any) string { + if s, ok := v.(string); ok { + return s + } + b, _ := json.Marshal(v) + return string(b) +} + +func sortedKeysAny(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, ",") } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } diff --git a/cmd/gbrgames/firstboot_test.go b/cmd/gbrgames/firstboot_test.go new file mode 100644 index 0000000..e318eb2 --- /dev/null +++ b/cmd/gbrgames/firstboot_test.go @@ -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) + } + } +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 829d15a..be9ba92 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -5,6 +5,7 @@ // go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel // go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs // go run ./cmd/gbrgames inspect-image -game ID # read the image's facts and check the template against them +// go run ./cmd/gbrgames first-boot -game ID # start the server once, untouched; record its log and files package main import ( @@ -28,6 +29,8 @@ func main() { err = runBootTestPayload(os.Args[2:]) case "inspect-image": err = runInspectImage(os.Args[2:]) + case "first-boot": + err = runFirstBoot(os.Args[2:]) default: usage() } @@ -38,6 +41,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE]") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID | inspect-image [-game ID] [-json] [-no-pull] [IMAGE] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR]") os.Exit(2) }