Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba09fe35d7 | ||
|
|
a8394abe05 | ||
|
|
c9d9cd6ff5 | ||
|
|
40e7372dab |
No files matched your search
@@ -4,3 +4,6 @@ export.json
|
|||||||
|
|
||||||
/gbrgames
|
/gbrgames
|
||||||
/gbrgames.exe
|
/gbrgames.exe
|
||||||
|
|
||||||
|
# first-boot reports (logs and copied server files)
|
||||||
|
firstboot-out/
|
||||||
@@ -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.
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user