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) }