Compare commits

...
1 Commits
Author SHA1 Message Date
gregandClaude Opus 5.5 8623ba2d7b feat(builder): documented launch options only, and no game-specific code
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
Launch options must be documented. A draft passed an option its server
doesn't have; servers ignore unknown options, so it booted and passed every
boot test while its setting did nothing. build now reads the pages research
based the variant on, in full, and the drafter rejects any launch option in
the startup command that none of them, nor the image's own scripts, mention.
Shell tools' options (mkdir -p, [ -n ...]) are skipped; the check is off
without sources.

The builder is game-agnostic:
- AUTHORING.md (the model's rules), the research prompt and the schema
  describe the rules without naming a game.
- The draft prompt no longer includes real templates from the repo. Its
  format example is one invented server (no real command, options, ports or
  app id), checked by TestExampleTemplateFollowsTheRules.
- Comments, help texts, BUILDER.md and test fixtures use invented names and
  values.
- TestBuilderIsGameAgnostic reads every game's name and id from games/ and
  fails if any appears in the builder's code, tests, rules, schemas or docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:58:53 -04:00
16 changed files with 395 additions and 120 deletions

No files matched your search

+7 -4
View File
@@ -47,22 +47,25 @@ Know this before writing anything:
### The container user ### The container user
- Run `gbrgames inspect-image -game <id>`. If the image is built around its own user (it declares - Run `gbrgames inspect-image -game <id>`. If the image is built around its own user (it declares
`USER`, or locks a home folder to a uid other than 1000), set `containerUser` to that `uid:gid`. `USER`, or locks a home folder to a uid other than 1000), set `containerUser` to that `uid:gid`, as
Example: ARK's image needs `25000:25000`. the inspector reports it.
### The startup command ### The startup command
- Read settings under the names the agent gives them (see above). `${RCON_PORT}` when the setting is - Read settings under the names the agent gives them (see above). `${RCON_PORT}` when the setting is
`RCONPort` silently ignores the owner's value; it arrives as `RCONPORT`. `RCONPort` silently ignores the owner's value; it arrives as `RCONPORT`.
- Use `${VAR:-default}` for anything that may be unset. - Use `${VAR:-default}` for anything that may be unset.
- Only use launch options the game's documentation lists, spelled as it spells them. Servers ignore options
they don't know, so an invented one boots fine and its setting does nothing.
- Quote text settings where they're used: `-name "${SERVER_NAME}"`. `ARGS="-name ${SERVER_NAME}"; ./server $ARGS` - Quote text settings where they're used: `-name "${SERVER_NAME}"`. `ARGS="-name ${SERVER_NAME}"; ./server $ARGS`
splits "GBRHosting - My Server" into words; build optional arguments in a bash array instead splits "GBRHosting - My Server" into words; build optional arguments in a bash array instead
(`args=(-name "$SERVER_NAME"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./server "${args[@]}"`). (`args=(-name "$SERVER_NAME"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./server "${args[@]}"`).
- If the image installs the game itself on start, make sure a failed install is retried rather than - If the image installs the game itself on start, make sure a failed install is retried rather than
starting a server that isn't there (see ARK's template). starting a server that isn't there: log in to SteamCMD once (`+login anonymous +quit`) before the
install, and retry the install a few times until the server's executable exists.
### Memory ### Memory
- `requiredMemoryMb` is the container's hard limit. A JVM or engine heap must stay about **1 GB below** - `requiredMemoryMb` is the container's hard limit. A JVM or engine heap must stay about **1 GB below**
it: Minecraft's `MEMORY` is `3G` for `4096` MB. A heap equal to the limit is killed on start. it: a `3G` heap for `4096` MB. A heap equal to the limit is killed on start.
### Settings (`defaultConfig` and `configSchema`) ### Settings (`defaultConfig` and `configSchema`)
- Take keys and default values from what the server really writes: run - Take keys and default values from what the server really writes: run
+8 -3
View File
@@ -25,12 +25,17 @@ progress / log / variants ◄─post── build: first boot → draft
validates but never booted still gets a PR, titled `WIP:`, with what's left listed. Nothing reaches validates but never booted still gets a PR, titled `WIP:`, with what's left listed. Nothing reaches
players until a person merges it and enables the game. players until a person merges it and enables the game.
The builder is **game-agnostic**: everything about a game comes from that game's research, first boot and
boot tests. No game's name, id or code may appear in the builder's source, its rules for the model
(`AUTHORING.md`), the schemas or this file, and the model is never shown another game's template (its format
example is an invented server). `TestBuilderIsGameAgnostic` fails the build if one does.
The worker only connects out (to the panel, Gitea, the model and the search services). The worker only connects out (to the panel, Gitea, the model and the search services).
## Requirements ## Requirements
A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the A Docker host with the isolated `gbr-boottest` network (`RUNNER.md`, step 1), so servers booted by the
builder can't reach private networks. Enough memory for the largest server it builds (~16 GB for ARK) and builder can't reach private networks. Enough memory for the largest server it builds (the biggest need ~16 GB) and
disk for SteamCMD downloads. The image `gbr-builder:latest` is published by disk for SteamCMD downloads. The image `gbr-builder:latest` is published by
`.gitea/workflows/builder-image.yml` whenever `cmd/` changes on main. `.gitea/workflows/builder-image.yml` whenever `cmd/` changes on main.
@@ -54,9 +59,9 @@ On any machine with Docker and the `gbr-boottest` network, with `OLLAMA_URL`, `S
`READER_URL` set: `READER_URL` set:
```bash ```bash
go run ./cmd/gbrgames research -name Valheim -out research.json go run ./cmd/gbrgames research -name "<game>" -out research.json
# pick a variant from research.json into v.json, then: # pick a variant from research.json into v.json, then:
go run ./cmd/gbrgames build -name Valheim -variant @v.json -out build.json go run ./cmd/gbrgames build -name "<game>" -variant @v.json -out build.json
``` ```
The draft lands in `games/<id>/`, first-boot reports in `firstboot-out/`. The draft lands in `games/<id>/`, first-boot reports in `firstboot-out/`.
+51
View File
@@ -0,0 +1,51 @@
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// The builder must work the same for every game: no game's name, id or code in its source, its rules for
// the model (AUTHORING.md), the schemas sent with every draft, or its docs. Games are read from games/,
// so a newly merged template is covered as soon as it lands.
func TestBuilderIsGameAgnostic(t *testing.T) {
root := filepath.Join("..", "..")
templates, _ := filepath.Glob(filepath.Join(root, "games", "*", "game.yaml"))
if len(templates) == 0 {
t.Fatal("no templates found to check against")
}
var names []string
for _, f := range templates {
g, err := loadMap(f)
if err != nil {
continue
}
for _, key := range []string{"id", "name"} {
if s, _ := g[key].(string); len(s) > 2 {
names = append(names, s)
}
}
}
files, _ := filepath.Glob("*.go")
files = append(files, filepath.Join(root, "AUTHORING.md"), filepath.Join(root, "BUILDER.md"))
schemas, _ := filepath.Glob(filepath.Join(root, "schema", "*.json"))
files = append(files, schemas...)
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
text := string(raw)
for _, n := range names {
re := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9])` + regexp.QuoteMeta(n) + `($|[^A-Za-z0-9])`)
if loc := re.FindStringIndex(text); loc != nil {
line := strings.Count(text[:loc[0]], "\n") + 1
t.Errorf("%s:%d mentions %q: the builder must not name or copy any game", f, line, n)
}
}
}
}
+26 -1
View File
@@ -138,7 +138,8 @@ func build(o buildOptions) (*buildResult, error) {
steamApp = o.variant.SteamAppID steamApp = o.variant.SteamAppID
} }
d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: res.Image, d, err := newDrafter(draftJob{GamesDir: o.gamesDir, SchemaDir: o.schemaDir, Name: o.name, ID: o.id, Image: res.Image,
SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true}) SteamApp: steamApp, Notes: variantNotes(o.variant, o.notes), Report: report, Provider: o.provider, Model: o.model, NoPull: true,
Docs: sourceText(o.variant.Sources)})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -370,3 +371,27 @@ func buildSummary(o buildOptions, res *buildResult) string {
} }
return b.String() return b.String()
} }
// sourceText reads the pages research based the variant on, in full, so the drafter can check that every
// launch option the model uses is documented there. Empty (check skipped) without a reader.
func sourceText(sources []string) string {
reader := os.Getenv("READER_URL")
if reader == "" || len(sources) == 0 {
fmt.Println("[build] no research sources to check launch options against (READER_URL or sources missing)")
return ""
}
var b strings.Builder
read := 0
for _, s := range sources {
text, err := readPageLimit(reader, s, 200_000)
if err != nil {
fmt.Printf("[build] could not read %s: %v\n", s, err)
continue
}
read++
b.WriteString(text)
b.WriteString("\n")
}
fmt.Printf("[build] read %d of %d research sources to check launch options against\n", read, len(sources))
return b.String()
}
+8 -8
View File
@@ -6,12 +6,12 @@ import (
) )
func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) { func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) {
out := "[boot-test] deploying boottest-valheim (cm2network/steamcmd)\n" + out := "[boot-test] deploying boottest-example (cm2network/steamcmd)\n" +
"[boot-test] container started; waiting for ready pattern /Game server connected/\n" + "[boot-test] container started; waiting for ready pattern /Server ready/\n" +
"[boot-test] FAILED: container exited before it was ready\n" + "[boot-test] FAILED: container exited before it was ready\n" +
"---- last 2 log lines ----\n" + "---- last 2 log lines ----\n" +
"Unknown argument --save-dir\n" + "Unknown argument --save-dir\n" +
"Usage: valheim_server.x86_64 ...\n" "Usage: example_server ...\n"
got := bootFailure(out) got := bootFailure(out)
if !strings.Contains(got, "failed: FAILED: container exited before it was ready") { if !strings.Contains(got, "failed: FAILED: container exited before it was ready") {
t.Errorf("verdict missing: %s", got) t.Errorf("verdict missing: %s", got)
@@ -19,19 +19,19 @@ func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) {
if !strings.Contains(got, "Unknown argument --save-dir") || strings.Contains(got, "deploying boottest") { if !strings.Contains(got, "Unknown argument --save-dir") || strings.Contains(got, "deploying boottest") {
t.Errorf("want only the server's last lines, got: %s", got) t.Errorf("want only the server's last lines, got: %s", got)
} }
if v := bootVerdict("[boot-test] PASSED: READY: Game server connected"); v != "PASSED: READY: Game server connected" { if v := bootVerdict("[boot-test] PASSED: READY: Server ready"); v != "PASSED: READY: Server ready" {
t.Errorf("verdict = %q", v) t.Errorf("verdict = %q", v)
} }
} }
func TestOutsideDirsSkipsSteamNoise(t *testing.T) { func TestOutsideDirsSkipsSteamNoise(t *testing.T) {
r := &bootReport{ContainerChanges: []string{ r := &bootReport{ContainerChanges: []string{
"A /home/steam/.config/unity3d/IronGate/Valheim/worlds_local/w.db", "A /home/steam/.config/example-game/worlds/w.db",
"A /home/steam/Steam/logs/connection_log.txt", "A /home/steam/Steam/logs/connection_log.txt",
"C /home/steam/.steam/registry.vdf", "C /home/steam/.steam/registry.vdf",
}} }}
got := outsideDirs(r) got := outsideDirs(r)
if len(got) != 1 || got[0] != "/home/steam/.config/unity3d/IronGate/Valheim/worlds_local" { if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" {
t.Errorf("got %v", got) t.Errorf("got %v", got)
} }
if outsideDirs(nil) != nil { if outsideDirs(nil) != nil {
@@ -40,9 +40,9 @@ func TestOutsideDirsSkipsSteamNoise(t *testing.T) {
} }
func TestVariantNotes(t *testing.T) { func TestVariantNotes(t *testing.T) {
v := variant{Title: "Crossplay", Description: "PC + Xbox", StartupHint: "./valheim_server.x86_64 -crossplay", Ports: []string{"2456/udp"}} v := variant{Title: "Crossplay", Description: "PC + Xbox", StartupHint: "./example_server -crossplay", Ports: []string{"30000/udp"}}
got := variantNotes(v, "needs Xbox") got := variantNotes(v, "needs Xbox")
for _, want := range []string{"variant: Crossplay (PC + Xbox)", "documented startup: ./valheim_server.x86_64 -crossplay", "2456/udp", "the admin asked for: needs Xbox"} { for _, want := range []string{"variant: Crossplay (PC + Xbox)", "documented startup: ./example_server -crossplay", "30000/udp", "the admin asked for: needs Xbox"} {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Errorf("missing %q in %q", want, got) t.Errorf("missing %q in %q", want, got)
} }
+61 -29
View File
@@ -13,7 +13,6 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"sort"
"strings" "strings"
"time" "time"
@@ -39,7 +38,7 @@ func runDraft(args []string) error {
fs := flag.NewFlagSet("draft", flag.ExitOnError) fs := flag.NewFlagSet("draft", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game") gamesDir := fs.String("games", "games", "directory holding one folder per game")
schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas") schemaDir := fs.String("schemas", "schema", "directory holding the JSON Schemas")
gameName := fs.String("name", "", "the game's name, e.g. \"Valheim\"") gameName := fs.String("name", "", "the game's name, as players know it")
id := fs.String("id", "", "folder/id for the new game (default: from -name)") id := fs.String("id", "", "folder/id for the new game (default: from -name)")
image := fs.String("image", "", "Docker image the server runs in") image := fs.String("image", "", "Docker image the server runs in")
steamApp := fs.String("steam-app", "", "SteamCMD app id, if the server is installed with SteamCMD") steamApp := fs.String("steam-app", "", "SteamCMD app id, if the server is installed with SteamCMD")
@@ -82,6 +81,9 @@ type draftJob struct {
Report *bootReport Report *bootReport
Provider, Model string Provider, Model string
NoPull bool NoPull bool
// Text of the pages research based the variant on; when set, every launch option in the startup
// command must appear in it (or in the image's scripts)
Docs string
} }
// drafter holds one conversation with the model about one game, so later problems (a failed boot) can // drafter holds one conversation with the model about one game, so later problems (a failed boot) can
@@ -137,7 +139,7 @@ func newDrafter(j draftJob) (*drafter, error) {
} }
d.messages = []chatMessage{ d.messages = []chatMessage{
{Role: "system", Content: draftSystemPrompt(string(rules))}, {Role: "system", Content: draftSystemPrompt(string(rules))},
{Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report, exampleTemplates(j.GamesDir, 2))}, {Role: "user", Content: draftRequest(j.Name, id, j.Image, j.SteamApp, j.Notes, d.facts, j.Report)},
} }
return d, nil return d, nil
} }
@@ -200,6 +202,7 @@ func (d *drafter) check(answer string) []string {
problems = append(problems, savesOutsideVolumes(g, d.job.Report)...) problems = append(problems, savesOutsideVolumes(g, d.job.Report)...)
problems = append(problems, splitSettings(g)...) problems = append(problems, splitSettings(g)...)
problems = append(problems, portSettings(g)...) problems = append(problems, portSettings(g)...)
problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...)
} }
} }
return problems return problems
@@ -337,7 +340,7 @@ func closestVar(name string, vars []string) string {
var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"} var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"}
// savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything the // savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything the
// game itself wrote there (Valheim's worlds and admin lists in ~/.config/unity3d/...) is lost every time the // game itself wrote there (worlds or player lists under the user's home folder) is lost every time the
// node recreates the container. A template passes once its startup command or settings point the game at // node recreates the container. A template passes once its startup command or settings point the game at
// /server or /data; a boot test then shows whether that really worked. // /server or /data; a boot test then shows whether that really worked.
func savesOutsideVolumes(game map[string]any, report *bootReport) []string { func savesOutsideVolumes(game map[string]any, report *bootReport) []string {
@@ -403,7 +406,7 @@ func imageUnusable(r *bootReport) string {
*r.ExitCode, r.RanSeconds, r.User, denied[0]) *r.ExitCode, r.RanSeconds, r.User, denied[0])
} }
func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts, report *bootReport, examples string) string { func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts, report *bootReport) string {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "Write the template for **%s**.\n- id: %s\n- defaultDockerImage: %s\n", gameName, id, image) fmt.Fprintf(&b, "Write the template for **%s**.\n- id: %s\n- defaultDockerImage: %s\n", gameName, id, image)
if steamApp != "" { if steamApp != "" {
@@ -447,33 +450,62 @@ func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts
} else { } else {
b.WriteString("\n(No first-boot report: keep readyPattern out unless the facts show the ready line.)\n") b.WriteString("\n(No first-boot report: keep readyPattern out unless the facts show the ready line.)\n")
} }
b.WriteString("\n## Working templates from this repo, for format and style (not content)\n") b.WriteString("\n## Format example\nAn invented server, to show the format and house style only. Its command, options, " +
b.WriteString(examples) "ports and app id belong to no real game: never copy them; take every value from the facts above.\n```yaml\n")
b.WriteString(exampleTemplate)
b.WriteString("```\n")
return b.String() return b.String()
} }
// exampleTemplates picks the shortest templates in the repo as examples: real, validated, and small // exampleTemplate shows the model the format only. It's invented on purpose: the builder must stay
// enough to leave the model room for the facts. // game-agnostic, so the model never sees another game's template (whose options it would copy). Its
func exampleTemplates(gamesDir string, n int) string { // command, options, port and app id belong to no real server; validated in draft_test.go.
entries, _ := os.ReadDir(gamesDir) const exampleTemplate = `id: example-server
type ex struct { name: Example Server
id string defaultDockerImage: cm2network/steamcmd
body string defaultStartupCommand: args=(-name "${SERVER_NAME}" -world "${WORLD_NAME:-world}" -port 30000 -datadir /server/saves); [ -n "${JOIN_PASSWORD}" ] && args+=(-password "${JOIN_PASSWORD}"); ./example_server "${args[@]}"
} requiredMemoryMb: 4096
var all []ex requiredDiskMb: 8192
for _, e := range entries { defaultPorts:
raw, err := os.ReadFile(filepath.Join(gamesDir, e.Name(), "game.yaml")) - 30000/udp
if err == nil { - 30001/udp
all = append(all, ex{e.Name(), string(raw)}) installMethod: STEAMCMD
} installConfig:
} appId: "1000000"
sort.Slice(all, func(i, j int) bool { return len(all[i].body) < len(all[j].body) }) defaultConfig:
var b strings.Builder JoinPassword: ""
for i := 0; i < n && i < len(all); i++ { ServerName: GBRHosting - {SERVER_NAME}
fmt.Fprintf(&b, "\n### games/%s/game.yaml\n```yaml\n%s```\n", all[i].id, all[i].body) WorldName: world
} configSchema:
return b.String() type: object
} properties:
JoinPassword:
title: Join Password
description: Password players enter to join. Leave empty for a public server.
type: string
default: ""
category: Security & Access
x-savedValue:
key: GAME_JOIN_PASSWORD
label: Game join password
secret: true
ServerName:
title: Server Name
description: The name shown in the server browser.
type: string
default: GBRHosting - {SERVER_NAME}
category: System & General
showOnDeploy: true
WorldName:
title: World Name
description: The world to load. A new name starts a new world.
type: string
default: world
category: World & Environment
backupPaths:
- saves
readyPattern: 'Server is ready for connections'
`
// writeDraft turns the model's JSON answer into games/<id>/game.yaml and test.yaml, using the importer's // writeDraft turns the model's JSON answer into games/<id>/game.yaml and test.yaml, using the importer's
// writer so drafts look like every other template. // writer so drafts look like every other template.
+53 -18
View File
@@ -2,14 +2,16 @@ package main
import ( import (
"encoding/json" "encoding/json"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
) )
func TestDraftCompletenessNamesMissingSchemaEntries(t *testing.T) { func TestDraftCompletenessNamesMissingSchemaEntries(t *testing.T) {
// The Valheim draft that went round in circles: settings in defaultConfig, no configSchema // A draft that went round in circles: settings in defaultConfig, no configSchema
game := map[string]any{ game := map[string]any{
"defaultPorts": []any{"2456/udp"}, "defaultPorts": []any{"30000/udp"},
"requiredMemoryMb": json.Number("4096"), "requiredMemoryMb": json.Number("4096"),
"defaultConfig": map[string]any{"ServerName": "x", "ServerPassword": "", "MaxPlayers": json.Number("8")}, "defaultConfig": map[string]any{"ServerName": "x", "ServerPassword": "", "MaxPlayers": json.Number("8")},
} }
@@ -44,28 +46,28 @@ func TestUnreadSettings(t *testing.T) {
} }
func TestSavesOutsideVolumes(t *testing.T) { func TestSavesOutsideVolumes(t *testing.T) {
// Valheim's real first boot: its own files in ~/.config, plus Steam client logs (noise) // A real first boot: the game's own files in ~/.config, plus Steam client logs (noise)
valheim := &bootReport{ContainerChanges: []string{ home := &bootReport{ContainerChanges: []string{
"A /home/steam/.config/unity3d/IronGate/Valheim/adminlist.txt", "A /home/steam/.config/example-game/adminlist.txt",
"A /home/steam/.config/unity3d/IronGate/Valheim/bannedlist.txt", "A /home/steam/.config/example-game/bannedlist.txt",
"A /home/steam/Steam/logs/connection_log_2456.txt", "A /home/steam/Steam/logs/connection_log_30000.txt",
}} }}
game := map[string]any{"defaultStartupCommand": "./valheim_server.x86_64 -nographics -batchmode -world X"} game := map[string]any{"defaultStartupCommand": "./example_server -nographics -batchmode -world X"}
problems := savesOutsideVolumes(game, valheim) problems := savesOutsideVolumes(game, home)
if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/unity3d/IronGate/Valheim") || strings.Contains(problems[0], "Steam/logs") { if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/example-game") || strings.Contains(problems[0], "Steam/logs") {
t.Fatalf("got %v", problems) t.Fatalf("got %v", problems)
} }
// Fixed with a save-directory option under /server // Fixed with a save-directory option under /server
game["defaultStartupCommand"] = "./valheim_server.x86_64 -nographics -batchmode -world X -savedir /server/saves" game["defaultStartupCommand"] = "./example_server -nographics -batchmode -world X -datadir /server/saves"
if p := savesOutsideVolumes(game, valheim); p != nil { if p := savesOutsideVolumes(game, home); p != nil {
t.Errorf("redirected template still flagged: %v", p) t.Errorf("redirected template still flagged: %v", p)
} }
// ARK's real first boot: only Proton's cache, which is not game data // A Proton server's first boot: only Proton's cache, which is not game data
ark := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}} proton := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}}
if p := savesOutsideVolumes(map[string]any{}, ark); p != nil { if p := savesOutsideVolumes(map[string]any{}, proton); p != nil {
t.Errorf("ARK proton cache flagged: %v", p) t.Errorf("proton cache flagged: %v", p)
} }
} }
@@ -86,10 +88,43 @@ func TestImageUnusable(t *testing.T) {
} }
func TestDraftCompletenessRejectsSecrets(t *testing.T) { func TestDraftCompletenessRejectsSecrets(t *testing.T) {
// The Valheim draft that couldn't be boot-tested: the password mapped to a CI secret nobody created // A draft that couldn't be boot-tested: the password mapped to a CI secret nobody created
test := map[string]any{"settleSec": json.Number("120"), "secrets": map[string]any{"Password": "VALHEIM_TEST_PASSWORD"}} test := map[string]any{"settleSec": json.Number("120"), "secrets": map[string]any{"Password": "EXAMPLE_TEST_PASSWORD"}}
problems := strings.Join(draftCompleteness(map[string]any{}, test), "\n") problems := strings.Join(draftCompleteness(map[string]any{}, test), "\n")
if !strings.Contains(problems, "test.yaml has 'secrets' (Password)") { if !strings.Contains(problems, "test.yaml has 'secrets' (Password)") {
t.Errorf("got:\n%s", problems) t.Errorf("got:\n%s", problems)
} }
} }
// The model copies the example's format, so it must pass everything a draft has to pass
func TestExampleTemplateFollowsTheRules(t *testing.T) {
dir := filepath.Join(t.TempDir(), "example-server")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "game.yaml"), []byte(exampleTemplate), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "test.yaml"), []byte("timeoutSec: 2400\n"), 0o644); err != nil {
t.Fatal(err)
}
gs, err := compileSchema(filepath.Join("..", "..", "schema", "game.schema.json"))
if err != nil {
t.Fatal(err)
}
ts, err := compileSchema(filepath.Join("..", "..", "schema", "test.schema.json"))
if err != nil {
t.Fatal(err)
}
problems := validateGame(dir, "example-server", gs, ts)
g, err := loadMap(filepath.Join(dir, "game.yaml"))
if err != nil {
t.Fatal(err)
}
problems = append(problems, draftCompleteness(g, map[string]any{})...)
problems = append(problems, splitSettings(g)...)
problems = append(problems, portSettings(g)...)
for _, p := range problems {
t.Error(p)
}
}
+76 -1
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path"
"regexp" "regexp"
"sort" "sort"
"strconv" "strconv"
@@ -75,7 +76,7 @@ func splitSettings(game map[string]any) []string {
var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`) var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`)
// unwrapShell looks inside a startup command that is one bash -c '...' (ARK's), which would otherwise be // unwrapShell looks inside a startup command that is one bash -c '...', which would otherwise be
// all single-quoted text. // all single-quoted text.
func unwrapShell(cmd string) string { func unwrapShell(cmd string) string {
if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") { if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") {
@@ -248,3 +249,77 @@ func portNumber(v any) (int, bool) {
p, err := strconv.Atoi(strings.TrimSpace(s)) p, err := strconv.Atoi(strings.TrimSpace(s))
return p, err == nil && p > 0 && p < 65536 return p, err == nil && p > 0 && p < 65536
} }
var (
optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`)
segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`)
testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true}
// Options of these are the shell's and its tools', not the game's
shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true,
"wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true,
"sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true,
"cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true}
)
// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor
// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one
// boots fine and passes every boot test while its setting does nothing.
func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string {
if strings.TrimSpace(docs) == "" {
return nil
}
startup, _ := game["defaultStartupCommand"].(string)
corpus := docs
if facts != nil {
for _, s := range facts.Scripts {
corpus += "\n" + s
}
}
seen := map[string]bool{}
var missing []string
for _, opt := range launchOptions(unwrapShell(startup)) {
name := strings.TrimLeft(opt, "-")
key := strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`)
if !documented.MatchString(corpus) {
missing = append(missing, opt)
}
}
if len(missing) == 0 {
return nil
}
return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+
"Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+
"only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))}
}
// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]).
func launchOptions(cmd string) []string {
var out []string
for _, seg := range segmentSep.Split(cmd, -1) {
fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({"))
i := 0
for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" ||
(assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) {
i++
}
if i == len(fields) {
continue
}
if word := path.Base(fields[i]); shellTools[word] {
continue
}
for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) {
name := strings.TrimLeft(m[1], "-")
if len(name) < 2 || testOps[name] {
continue
}
out = append(out, m[1])
}
}
return out
}
+54 -10
View File
@@ -14,12 +14,12 @@ func textSettings(keys ...string) map[string]any {
return map[string]any{"properties": props} return map[string]any{"properties": props}
} }
func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) { func TestSplitSettingsCatchesAnArgsString(t *testing.T) {
// What the builder drafted for Valheim: quoted in the assignment, split by the unquoted $ARGS // A real draft: quoted in the assignment, split by the unquoted $ARGS
game := map[string]any{ game := map[string]any{
"defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Valheim Server} -port 2456 -world ${WORLD_NAME:-Dedicated}"; ` + "defaultStartupCommand": `ARGS="-name ${SERVER_NAME:-Example Server} -port 30000 -world ${WORLD_NAME:-Dedicated}"; ` +
`[ -n "$PASSWORD" ] && ARGS="$ARGS -password $PASSWORD"; [ "$CROSSPLAY" = "true" ] && ARGS="$ARGS -crossplay"; ` + `[ -n "$PASSWORD" ] && ARGS="$ARGS -password $PASSWORD"; [ "$CROSSPLAY" = "true" ] && ARGS="$ARGS -crossplay"; ` +
`HOME=/server ./valheim_server.x86_64 $ARGS`, `HOME=/server ./example_server $ARGS`,
"configSchema": textSettings("ServerName", "WorldName", "Password"), "configSchema": textSettings("ServerName", "WorldName", "Password"),
} }
problems := splitSettings(game) problems := splitSettings(game)
@@ -36,8 +36,8 @@ func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) {
func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) { func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) {
for _, startup := range []string{ for _, startup := range []string{
`./valheim_server.x86_64 -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 2456`, `./example_server -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 30000`,
`args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./valheim_server.x86_64 "${args[@]}"`, `args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./example_server "${args[@]}"`,
`bash -c 'exec ./server -name "$SERVER_NAME"'`, `bash -c 'exec ./server -name "$SERVER_NAME"'`,
} { } {
game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName", "WorldName", "Password")} game := map[string]any{"defaultStartupCommand": startup, "configSchema": textSettings("ServerName", "WorldName", "Password")}
@@ -72,12 +72,12 @@ func TestSplitSettingsDirectAndArrayMisuse(t *testing.T) {
func TestPortSettings(t *testing.T) { func TestPortSettings(t *testing.T) {
game := map[string]any{ game := map[string]any{
"defaultPorts": []any{"2456/udp", "2457/udp"}, "defaultPorts": []any{"30000/udp", "30001/udp"},
"defaultConfig": map[string]any{ "defaultConfig": map[string]any{
"GamePort": json.Number("2456"), "GamePort": json.Number("30000"),
"QueryPort": json.Number("27015"), "QueryPort": json.Number("27015"),
"RCONPort": json.Number("25575"), "RCONPort": json.Number("25575"),
"PublicPort": "{PUBLIC_PORT_2456}", "PublicPort": "{PUBLIC_PORT_30000}",
"Transport": "udp", "Transport": "udp",
"Export": json.Number("9"), "Export": json.Number("9"),
}, },
@@ -86,7 +86,7 @@ func TestPortSettings(t *testing.T) {
}}, }},
} }
got := strings.Join(portSettings(game), "\n") got := strings.Join(portSettings(game), "\n")
if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 2456 directly") { if !strings.Contains(got, "setting GamePort lets owners change the port") || !strings.Contains(got, "write 30000 directly") {
t.Errorf("editable published port not caught:\n%s", got) t.Errorf("editable published port not caught:\n%s", got)
} }
if !strings.Contains(got, "setting QueryPort defaults to port 27015, which defaultPorts does not publish") { if !strings.Contains(got, "setting QueryPort defaults to port 27015, which defaultPorts does not publish") {
@@ -105,3 +105,47 @@ func TestPortSettings(t *testing.T) {
t.Errorf("got %v", p) t.Errorf("got %v", p)
} }
} }
func TestUndocumentedOptionsCatchesMaxPlayers(t *testing.T) {
// A real draft's startup: every option is documented except -maxplayers
game := map[string]any{"defaultStartupCommand": `args=(-name "${SERVER_NAME:-GBRHosting - Example}" -port 30000 -world "${WORLD_NAME:-Dedicated}" -datadir /server); ` +
`[ -n "${SERVER_PASSWORD}" ] && args+=(-password "${SERVER_PASSWORD}"); [ "${CROSSPLAY}" = "true" ] && args+=(-crossplay); ` +
`[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); ./example_server "${args[@]}"`}
docs := "Launch with `-name`, `-port`, `-world`, `-password`, `-datadir <path>` and `-crossplay` to allow consoles."
got := undocumentedOptions(game, docs, nil)
if len(got) != 1 || !strings.Contains(got[0], "passes -maxplayers,") {
t.Fatalf("want only -maxplayers flagged, got %v", got)
}
// Once removed, nothing is left to flag
game["defaultStartupCommand"] = strings.Replace(game["defaultStartupCommand"].(string), `[ -n "${MAX_PLAYERS}" ] && args+=(-maxplayers "${MAX_PLAYERS}"); `, "", 1)
if got := undocumentedOptions(game, docs, nil); len(got) != 0 {
t.Errorf("got %v", got)
}
}
func TestUndocumentedOptionsIgnoresShellAndUsesImageScripts(t *testing.T) {
game := map[string]any{"defaultStartupCommand": `mkdir -p /server/.config; cp -f /a /b; [ -n "$X" ] && [ "$Y" -eq 1 ] && ` +
`exec ./server --listen-port=7777 -ServerPlatform=ALL -logs`}
facts := &imageFacts{Scripts: map[string]string{"/start.sh": `exec "$@" -logs`}}
got := strings.Join(undocumentedOptions(game, "Use --listen-port and -ServerPlatform=ALL for crossplay.", facts), "\n")
if got != "" {
t.Errorf("shell options, documented ones and ones the image's script uses must pass, got %q", got)
}
// Without sources the check is off (draft run by hand, or no reader configured)
game["defaultStartupCommand"] = "./server -madeup"
if p := undocumentedOptions(game, "", nil); p != nil {
t.Errorf("got %v", p)
}
if p := undocumentedOptions(game, "unrelated page", nil); len(p) != 1 {
t.Errorf("want -madeup flagged, got %v", p)
}
}
func TestUndocumentedOptionsWholeWords(t *testing.T) {
// "-port" in the docs doesn't document "-portal", and "-port" isn't documented by "-portal"
game := map[string]any{"defaultStartupCommand": "./server -port 1 -portal x"}
got := strings.Join(undocumentedOptions(game, "options: -port", nil), "")
if !strings.Contains(got, "passes -portal,") || strings.Contains(got, "-port,") {
t.Errorf("got %q", got)
}
}
+2 -2
View File
@@ -466,8 +466,8 @@ func (b *booter) copyOut(vol, mount string, files []string, report *bootReport)
return cmd.Wait() return cmd.Wait()
} }
// fileLines reads the copied log files, so ready and error lines that never reach the console (ARK // fileLines reads the copied log files, so ready and error lines that never reach the console (servers
// writes to ShooterGame.log) are found too. // that only log to a file) are found too.
func (b *booter) fileLines(report *bootReport) []logLine { func (b *booter) fileLines(report *bootReport) []logLine {
var out []logLine var out []logLine
for _, f := range report.FilesCreated { for _, f := range report.FilesCreated {
+18 -18
View File
@@ -6,21 +6,21 @@ import (
) )
func TestParseConfigFormats(t *testing.T) { func TestParseConfigFormats(t *testing.T) {
// Palworld's Unreal-style list // An Unreal-style option list
pal := parseConfig("/server/Pal/Saved/Config/LinuxServer/PalWorldSettings.ini", "[/Script/Pal.PalGameWorldSettings]\n"+ list := parseConfig("/server/Game/Saved/Config/LinuxServer/GameSettings.ini", "[/Script/Game.WorldSettings]\n"+
`OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Palworld Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`) `OptionSettings=(Difficulty=None,ExpRate=1.000000,ServerName="Default Server",CrossplayPlatforms=(Steam,Xbox,PS5,Mac))`)
want := map[string]string{"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 Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"}
if len(pal) != len(want) { if len(list) != len(want) {
t.Fatalf("palworld: got %d settings: %+v", len(pal), pal) t.Fatalf("option list: got %d settings: %+v", len(list), list)
} }
for _, s := range pal { for _, s := range list {
if s.Section != "OptionSettings" || want[s.Key] != s.Value { if s.Section != "OptionSettings" || want[s.Key] != s.Value {
t.Errorf("palworld setting %+v", s) t.Errorf("option list setting %+v", s)
} }
} }
// Minecraft's server.properties // A server.properties file
mc := parseConfig("/data/server.properties", "#Minecraft server properties\nmax-players=20\nmotd=A Minecraft Server\nlevel-seed=\n") mc := parseConfig("/data/server.properties", "#Server properties\nmax-players=20\nmotd=A Server\nlevel-seed=\n")
if len(mc) != 3 || mc[0].Key != "max-players" || mc[0].Value != "20" || mc[2].Value != "" { if len(mc) != 3 || mc[0].Key != "max-players" || mc[0].Value != "20" || mc[2].Value != "" {
t.Errorf("properties: %+v", mc) t.Errorf("properties: %+v", mc)
} }
@@ -66,7 +66,7 @@ func TestAnalyzeLines(t *testing.T) {
{At: 2, Source: "console", Text: "Failed to load optional module"}, {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]: Done (8.1s)! For help, type \"help\""},
{At: 9, Source: "console", Text: "[09:00:01 INFO]: Thread RCON Listener started"}, {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 // After the stop at 200s: the shutdown, as a wrapper script 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: 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"}, {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) }, []logLine{{At: -1, Source: "/server/x.log", Text: "Listening on 0.0.0.0:7777"}}, 200)
@@ -90,13 +90,13 @@ func TestAnalyzeLines(t *testing.T) {
func TestConfigCandidates(t *testing.T) { func TestConfigCandidates(t *testing.T) {
for rel, want := range map[string]bool{ for rel, want := range map[string]bool{
"server.properties": true, "server.properties": true,
"Pal/Saved/Config/LinuxServer/x.ini": true, "Game/Saved/Config/LinuxServer/x.ini": true,
"ShooterGame/Saved/Logs/ShooterGame.log": true, "Game/Saved/Logs/Server.log": true,
"steamapps/appmanifest_123.acf": false, "steamapps/appmanifest_123.acf": false,
"Engine/Config/BaseEngine.ini": false, "Engine/Config/BaseEngine.ini": false,
"plugins/Geyser/config.yml": false, "plugins/SomePlugin/config.yml": false,
"world/level.dat": false, "world/level.dat": false,
} { } {
if got := isConfigCandidate(rel, 1000); got != want { if got := isConfigCandidate(rel, 1000); got != want {
t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want) t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want)
+4 -4
View File
@@ -278,8 +278,8 @@ func parseProbe(f *imageFacts, out string) {
} }
// suggestContainerUser works out whether the image only works as a specific user. Images either declare // 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, // it (USER) or are built around an account whose home folder nobody else may enter (e.g. a home folder
// uid 25000, with /home/gameserver at 700). // at mode 700 owned by a uid other than 1000).
func suggestContainerUser(f *imageFacts) { func suggestContainerUser(f *imageFacts) {
if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" { if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" {
resolved := resolveUser(f, u) resolved := resolveUser(f, u)
@@ -364,7 +364,7 @@ func checkTemplate(game map[string]any, f *imageFacts) (problems, notes []string
} }
if cmd, _ := game["defaultStartupCommand"].(string); cmd != "" { if cmd, _ := game["defaultStartupCommand"].(string); cmd != "" {
for _, m := range assignedVars.FindAllStringSubmatch(cmd, -1) { for _, m := range assignedVars.FindAllStringSubmatch(cmd, -1) {
provided[m[1]] = true // set by the command itself, e.g. export ASA_START_PARAMS=... provided[m[1]] = true // set by the command itself, e.g. export START_PARAMS=...
} }
withDefault := map[string]bool{} withDefault := map[string]bool{}
for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) { for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) {
@@ -410,7 +410,7 @@ func containerPort(p string) string {
return p return p
} }
// startupScriptPaths picks absolute paths out of a template's startup command (e.g. ARK's // startupScriptPaths picks absolute paths out of a template's startup command (e.g. an image's
// /usr/bin/start_server) so the probe reads those scripts too. // /usr/bin/start_server) so the probe reads those scripts too.
func startupScriptPaths(game map[string]any) []string { func startupScriptPaths(game map[string]any) []string {
cmd, _ := game["defaultStartupCommand"].(string) cmd, _ := game["defaultStartupCommand"].(string)
+8 -8
View File
@@ -21,7 +21,7 @@ func TestEnvNameMatchesNodeAgent(t *testing.T) {
} }
} }
// Probe output as ARK's image produces it (trimmed) // Probe output of an image built around its own user (trimmed)
const arkProbe = probeMarker + ` passwd const arkProbe = probeMarker + ` passwd
root:x:0:0:root:/root:/bin/bash root:x:0:0:root:/root:/bin/bash
gameserver:x:25000:25000::/home/gameserver:/bin/bash gameserver:x:25000:25000::/home/gameserver:/bin/bash
@@ -34,7 +34,7 @@ bash|/usr/bin/bash
` + probeMarker + ` script /usr/bin/start_server ` + probeMarker + ` script /usr/bin/start_server
#!/bin/bash #!/bin/bash
cd /home/gameserver/steamcmd cd /home/gameserver/steamcmd
echo "$ASA_START_PARAMS ${ENABLE_DEBUG} $HOME" echo "$START_PARAMS ${ENABLE_DEBUG} $HOME"
` `
func arkFacts() *imageFacts { func arkFacts() *imageFacts {
@@ -55,7 +55,7 @@ func TestProbeParsing(t *testing.T) {
if !strings.Contains(f.Scripts["/usr/bin/start_server"], "steamcmd") { if !strings.Contains(f.Scripts["/usr/bin/start_server"], "steamcmd") {
t.Fatalf("script not captured: %q", f.Scripts) t.Fatalf("script not captured: %q", f.Scripts)
} }
if want := []string{"ASA_START_PARAMS", "ENABLE_DEBUG"}; !reflect.DeepEqual(f.EnvRead, want) { if want := []string{"ENABLE_DEBUG", "START_PARAMS"}; !reflect.DeepEqual(f.EnvRead, want) {
t.Errorf("envRead = %v, want %v (HOME is common and left out)", f.EnvRead, want) t.Errorf("envRead = %v, want %v (HOME is common and left out)", f.EnvRead, want)
} }
} }
@@ -63,7 +63,7 @@ func TestProbeParsing(t *testing.T) {
func TestSuggestContainerUser(t *testing.T) { func TestSuggestContainerUser(t *testing.T) {
// Locked home owned by another uid: use that account, with its own group // Locked home owned by another uid: use that account, with its own group
if f := arkFacts(); f.SuggestedContainerUser != "25000:25000" { if f := arkFacts(); f.SuggestedContainerUser != "25000:25000" {
t.Errorf("ARK: suggested %q, want 25000:25000", f.SuggestedContainerUser) t.Errorf("suggested %q, want 25000:25000", f.SuggestedContainerUser)
} }
// uid 1000 images need nothing // uid 1000 images need nothing
@@ -92,9 +92,9 @@ func TestCheckTemplate(t *testing.T) {
f := arkFacts() f := arkFacts()
game := map[string]any{ game := map[string]any{
"defaultConfig": map[string]any{"RCONPort": 27020, "MaxPlayers": 70}, "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 // A command where 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 // 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}}"`, "defaultStartupCommand": `export START_PARAMS="${START_PARAMS:-${SERVER_MAP:-DefaultMap}?RCONPort=${RCON_PORT:-27020} -Max=${MAX_PLAYERS:-70} ${MISSING}}"`,
} }
problems, _ := checkTemplate(game, f) problems, _ := checkTemplate(game, f)
joined := strings.Join(problems, "\n") joined := strings.Join(problems, "\n")
@@ -103,7 +103,7 @@ func TestCheckTemplate(t *testing.T) {
t.Errorf("missing problem %q in:\n%s", want, joined) t.Errorf("missing problem %q in:\n%s", want, joined)
} }
} }
for _, unwanted := range []string{"ASA_START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} { for _, unwanted := range []string{"START_PARAMS", "SERVER_MAP", "MAX_PLAYERS}"} {
if strings.Contains(joined, unwanted) { if strings.Contains(joined, unwanted) {
t.Errorf("false problem about %s in:\n%s", unwanted, joined) t.Errorf("false problem about %s in:\n%s", unwanted, joined)
} }
@@ -114,7 +114,7 @@ func TestCheckTemplate(t *testing.T) {
// With the fixes, only the deliberately missing variable is left; 25000:1000 counts (same uid) // With the fixes, only the deliberately missing variable is left; 25000:1000 counts (same uid)
game["containerUser"] = "25000:1000" game["containerUser"] = "25000:1000"
game["defaultStartupCommand"] = `export ASA_START_PARAMS="${ASA_START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"` game["defaultStartupCommand"] = `export START_PARAMS="${START_PARAMS:-x}?RCONPort=${RCONPORT:-27020}"`
if problems, _ := checkTemplate(game, f); len(problems) != 0 { if problems, _ := checkTemplate(game, f); len(problems) != 0 {
t.Errorf("fixed template still has problems: %v", problems) t.Errorf("fixed template still has problems: %v", problems)
} }
+10 -5
View File
@@ -76,7 +76,7 @@ var researchSchema = map[string]any{
func runResearch(args []string) error { func runResearch(args []string) error {
fs := flag.NewFlagSet("research", flag.ExitOnError) fs := flag.NewFlagSet("research", flag.ExitOnError)
gameName := fs.String("name", "", "the game's name, e.g. \"Valheim\"") gameName := fs.String("name", "", "the game's name, as players know it")
notes := fs.String("notes", "", "what the admin asked for (crossplay, a modded edition...)") notes := fs.String("notes", "", "what the admin asked for (crossplay, a modded edition...)")
provider := fs.String("provider", "ollama", "ollama or gemini") provider := fs.String("provider", "ollama", "ollama or gemini")
modelName := fs.String("model", "", "model name (default as for draft)") modelName := fs.String("model", "", "model name (default as for draft)")
@@ -171,8 +171,8 @@ func research(m model, gameName, notes string, pages int) (*researchResult, erro
const researchPrompt = "You research how to host a game's dedicated server for GBR Hosting, a game server host. Servers run " + const researchPrompt = "You research how to host a game's dedicated server for GBR Hosting, a game server host. Servers run " +
"in Docker on Linux, as a non-root user, with a volume at /server. From the search results and pages given, list " + "in Docker on Linux, as a non-root user, with a volume at /server. From the search results and pages given, list " +
"the ways this game's dedicated server can be run (variants): different editions (e.g. Java vs Bedrock, Evolved vs " + "the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " +
"Ascended), crossplay builds, or notably different server images. For each variant give:\n" + "crossplay builds, or notably different server images. For each variant give:\n" +
"- images: Docker Hub images that run this server, best first (prefer images that run as a non-root user, and the " + "- images: Docker Hub images that run this server, best first (prefer images that run as a non-root user, and the " +
"plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " + "plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " +
"appear in the pages.\n" + "appear in the pages.\n" +
@@ -250,13 +250,18 @@ func dedupeHits(hits []searchHit) []searchHit {
} }
func readPage(reader, pageURL string) (string, error) { func readPage(reader, pageURL string) (string, error) {
return readPageLimit(reader, pageURL, 9000)
}
// readPageLimit reads a page as text through the reader, cut to limit bytes.
func readPageLimit(reader, pageURL string, limit int) (string, error) {
raw, err := httpGet(strings.TrimRight(reader, "/")+"/"+pageURL, 60*time.Second) raw, err := httpGet(strings.TrimRight(reader, "/")+"/"+pageURL, 60*time.Second)
if err != nil { if err != nil {
return "", err return "", err
} }
text := string(raw) text := string(raw)
if len(text) > 9000 { if len(text) > limit {
text = text[:9000] + "\n...(truncated)" text = text[:limit] + "\n...(truncated)"
} }
return text, nil return text, nil
} }
+8 -8
View File
@@ -4,17 +4,17 @@ import "testing"
func TestPickPagesPrefersDocsAndOnePerSite(t *testing.T) { func TestPickPagesPrefersDocsAndOnePerSite(t *testing.T) {
hits := []searchHit{ hits := []searchHit{
{URL: "https://blog.example.com/valheim-server"}, {URL: "https://blog.example.com/example-server"},
{URL: "https://github.com/a/valheim-docker"}, {URL: "https://github.com/a/example-docker"},
{URL: "https://github.com/b/other"}, {URL: "https://github.com/b/other"},
{URL: "https://hub.docker.com/r/x/valheim"}, {URL: "https://hub.docker.com/r/x/example"},
{URL: "https://blog.example.com/valheim-server"}, {URL: "https://blog.example.com/example-server"},
} }
got := pickPages(hits, 3) got := pickPages(hits, 3)
if len(got) != 3 { if len(got) != 3 {
t.Fatalf("got %d pages", len(got)) t.Fatalf("got %d pages", len(got))
} }
if got[0].URL != "https://github.com/a/valheim-docker" || got[1].URL != "https://hub.docker.com/r/x/valheim" || got[2].URL != "https://blog.example.com/valheim-server" { if got[0].URL != "https://github.com/a/example-docker" || got[1].URL != "https://hub.docker.com/r/x/example" || got[2].URL != "https://blog.example.com/example-server" {
t.Errorf("order: %v", got) t.Errorf("order: %v", got)
} }
} }
@@ -26,16 +26,16 @@ func TestTrimToJSON(t *testing.T) {
} }
func TestAppNameFromAppInfo(t *testing.T) { func TestAppNameFromAppInfo(t *testing.T) {
out := `"896660" out := `"1000000"
{ {
"common" "common"
{ {
"name" "Valheim Dedicated Server" "name" "Example Dedicated Server"
"type" "Tool" "type" "Tool"
} }
}` }`
m := appName.FindStringSubmatch(out) m := appName.FindStringSubmatch(out)
if m == nil || m[1] != "Valheim Dedicated Server" { if m == nil || m[1] != "Example Dedicated Server" {
t.Errorf("got %v", m) t.Errorf("got %v", m)
} }
} }
+1 -1
View File
@@ -17,7 +17,7 @@
"defaultDockerImage": { "description": "Image the game server runs in.", "type": "string", "minLength": 1 }, "defaultDockerImage": { "description": "Image the game server runs in.", "type": "string", "minLength": 1 },
"containerUser": { "containerUser": {
"description": "uid:gid the server runs as, for images built around their own user (ARK's needs 25000:25000). Defaults to 1000:1000; root is never allowed.", "description": "uid:gid the server runs as, for images built around their own user, as inspect-image reports it. Defaults to 1000:1000; root is never allowed.",
"type": ["string", "null"], "type": ["string", "null"],
"pattern": "^[1-9][0-9]{0,8}:[1-9][0-9]{0,8}$" "pattern": "^[1-9][0-9]{0,8}:[1-9][0-9]{0,8}$"
}, },