Compare commits

..
4 Commits
Author SHA1 Message Date
greg ba09fe35d7 Merge branch 'main' into fix/ark-install-retry
Validate game templates / validate (push) Successful in 9m33s
Validate game templates / boot-test (push) Successful in 15m20s
Validate game templates / publish (push) Skipped
2026-10-25 07:09:33 +00:00
greg a8394abe05 Merge pull request 'feat: inspect-image reads what a game's image needs' (#2) from feat/inspect-image into main
Validate game templates / validate (push) Successful in 9m33s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
2026-10-25 07:06:11 +00:00
gregandClaude Opus 5.5 c9d9cd6ff5 feat: first-boot records what a server does with no settings
Validate game templates / validate (push) Successful in 9m34s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
`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>
2026-10-25 02:18:21 -04:00
gregandClaude Opus 5.5 40e7372dab feat: inspect-image reads what a game's image needs
Validate game templates / validate (push) Successful in 9m33s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
`gbrgames inspect-image -game <id>` 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 <noreply@anthropic.com>
2026-10-25 01:42:55 -04:00
8 changed files with 1563 additions and 1 deletions

No files matched your search

+3
View File
@@ -4,3 +4,6 @@ export.json
/gbrgames /gbrgames
/gbrgames.exe /gbrgames.exe
# first-boot reports (logs and copied server files)
firstboot-out/
+6
View File
@@ -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 import admin-games.export.json # pull templates from GET /api/v1/admin/games
``` ```
`go run ./cmd/gbrgames inspect-image -game <id>` 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 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 keys exist, that consents don't default to accepted, and that no credentials or private network
addresses end up in a template. addresses end up in a template.
+800
View File
@@ -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/<name>)")
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 }
+105
View File
@@ -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)
}
}
}
+506
View File
@@ -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
}
+129
View File
@@ -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)
}
}
}
+7 -1
View File
@@ -4,6 +4,8 @@
// go run ./cmd/gbrgames import export.json # write games/<id>/ from GET /api/v1/admin/games // go run ./cmd/gbrgames import export.json # write games/<id>/ 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 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 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 package main
import ( import (
@@ -25,6 +27,10 @@ func main() {
err = runExport(os.Args[2:]) err = runExport(os.Args[2:])
case "boottest-payload": case "boottest-payload":
err = runBootTestPayload(os.Args[2:]) err = runBootTestPayload(os.Args[2:])
case "inspect-image":
err = runInspectImage(os.Args[2:])
case "first-boot":
err = runFirstBoot(os.Args[2:])
default: default:
usage() usage()
} }
@@ -35,6 +41,6 @@ func main() {
} }
func usage() { 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] | first-boot [-game ID | -image IMAGE] [-env K=V] [-max D] [-quiet D] [-out DIR]")
os.Exit(2) os.Exit(2)
} }
+7
View File
@@ -23,6 +23,13 @@ go build -o "$gbrgames" ./cmd/gbrgames || exit 1
passed=() failed=() skipped=() passed=() failed=() skipped=()
for id in "$@"; do for id in "$@"; do
echo "::group::boot test: $id" 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") payload=$("$gbrgames" boottest-payload "$id")
code=$? code=$?
if [ $code -eq 3 ]; then if [ $code -eq 3 ]; then