feat(builder): documented launch options only, and no game-specific code
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>
This commit is contained in:
1 parent
dd92b6be4c
commit
8623ba2d7b
16 files changed
+395
-120
No files matched your search
+7
-4
@@ -47,22 +47,25 @@ Know this before writing anything:
|
||||
|
||||
### The container user
|
||||
- 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`.
|
||||
Example: ARK's image needs `25000:25000`.
|
||||
`USER`, or locks a home folder to a uid other than 1000), set `containerUser` to that `uid:gid`, as
|
||||
the inspector reports it.
|
||||
|
||||
### The startup command
|
||||
- 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`.
|
||||
- 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`
|
||||
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[@]}"`).
|
||||
- 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
|
||||
- `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`)
|
||||
- Take keys and default values from what the server really writes: run
|
||||
|
||||
+8
-3
@@ -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
|
||||
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).
|
||||
|
||||
## Requirements
|
||||
|
||||
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
|
||||
`.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:
|
||||
|
||||
```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:
|
||||
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/`.
|
||||
@@ -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
@@ -138,7 +138,8 @@ func build(o buildOptions) (*buildResult, error) {
|
||||
steamApp = o.variant.SteamAppID
|
||||
}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
@@ -370,3 +371,27 @@ func buildSummary(o buildOptions, res *buildResult) 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()
|
||||
}
|
||||
@@ -6,12 +6,12 @@ import (
|
||||
)
|
||||
|
||||
func TestBootFailureKeepsVerdictAndServerOutput(t *testing.T) {
|
||||
out := "[boot-test] deploying boottest-valheim (cm2network/steamcmd)\n" +
|
||||
"[boot-test] container started; waiting for ready pattern /Game server connected/\n" +
|
||||
out := "[boot-test] deploying boottest-example (cm2network/steamcmd)\n" +
|
||||
"[boot-test] container started; waiting for ready pattern /Server ready/\n" +
|
||||
"[boot-test] FAILED: container exited before it was ready\n" +
|
||||
"---- last 2 log lines ----\n" +
|
||||
"Unknown argument --save-dir\n" +
|
||||
"Usage: valheim_server.x86_64 ...\n"
|
||||
"Usage: example_server ...\n"
|
||||
got := bootFailure(out)
|
||||
if !strings.Contains(got, "failed: FAILED: container exited before it was ready") {
|
||||
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") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutsideDirsSkipsSteamNoise(t *testing.T) {
|
||||
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",
|
||||
"C /home/steam/.steam/registry.vdf",
|
||||
}}
|
||||
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)
|
||||
}
|
||||
if outsideDirs(nil) != nil {
|
||||
@@ -40,9 +40,9 @@ func TestOutsideDirsSkipsSteamNoise(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")
|
||||
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) {
|
||||
t.Errorf("missing %q in %q", want, got)
|
||||
}
|
||||
|
||||
+61
-29
@@ -13,7 +13,6 @@ import (
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -39,7 +38,7 @@ func runDraft(args []string) error {
|
||||
fs := flag.NewFlagSet("draft", flag.ExitOnError)
|
||||
gamesDir := fs.String("games", "games", "directory holding one folder per game")
|
||||
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)")
|
||||
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")
|
||||
@@ -82,6 +81,9 @@ type draftJob struct {
|
||||
Report *bootReport
|
||||
Provider, Model string
|
||||
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
|
||||
@@ -137,7 +139,7 @@ func newDrafter(j draftJob) (*drafter, error) {
|
||||
}
|
||||
d.messages = []chatMessage{
|
||||
{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
|
||||
}
|
||||
@@ -200,6 +202,7 @@ func (d *drafter) check(answer string) []string {
|
||||
problems = append(problems, savesOutsideVolumes(g, d.job.Report)...)
|
||||
problems = append(problems, splitSettings(g)...)
|
||||
problems = append(problems, portSettings(g)...)
|
||||
problems = append(problems, undocumentedOptions(g, d.job.Docs, d.facts)...)
|
||||
}
|
||||
}
|
||||
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"}
|
||||
|
||||
// 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
|
||||
// /server or /data; a boot test then shows whether that really worked.
|
||||
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])
|
||||
}
|
||||
|
||||
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
|
||||
fmt.Fprintf(&b, "Write the template for **%s**.\n- id: %s\n- defaultDockerImage: %s\n", gameName, id, image)
|
||||
if steamApp != "" {
|
||||
@@ -447,33 +450,62 @@ func draftRequest(gameName, id, image, steamApp, notes string, facts *imageFacts
|
||||
} else {
|
||||
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(examples)
|
||||
b.WriteString("\n## Format example\nAn invented server, to show the format and house style only. Its command, options, " +
|
||||
"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()
|
||||
}
|
||||
|
||||
// exampleTemplates picks the shortest templates in the repo as examples: real, validated, and small
|
||||
// enough to leave the model room for the facts.
|
||||
func exampleTemplates(gamesDir string, n int) string {
|
||||
entries, _ := os.ReadDir(gamesDir)
|
||||
type ex struct {
|
||||
id string
|
||||
body string
|
||||
}
|
||||
var all []ex
|
||||
for _, e := range entries {
|
||||
raw, err := os.ReadFile(filepath.Join(gamesDir, e.Name(), "game.yaml"))
|
||||
if err == nil {
|
||||
all = append(all, ex{e.Name(), string(raw)})
|
||||
}
|
||||
}
|
||||
sort.Slice(all, func(i, j int) bool { return len(all[i].body) < len(all[j].body) })
|
||||
var b strings.Builder
|
||||
for i := 0; i < n && i < len(all); i++ {
|
||||
fmt.Fprintf(&b, "\n### games/%s/game.yaml\n```yaml\n%s```\n", all[i].id, all[i].body)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
// exampleTemplate shows the model the format only. It's invented on purpose: the builder must stay
|
||||
// game-agnostic, so the model never sees another game's template (whose options it would copy). Its
|
||||
// command, options, port and app id belong to no real server; validated in draft_test.go.
|
||||
const exampleTemplate = `id: example-server
|
||||
name: Example Server
|
||||
defaultDockerImage: cm2network/steamcmd
|
||||
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
|
||||
requiredDiskMb: 8192
|
||||
defaultPorts:
|
||||
- 30000/udp
|
||||
- 30001/udp
|
||||
installMethod: STEAMCMD
|
||||
installConfig:
|
||||
appId: "1000000"
|
||||
defaultConfig:
|
||||
JoinPassword: ""
|
||||
ServerName: GBRHosting - {SERVER_NAME}
|
||||
WorldName: world
|
||||
configSchema:
|
||||
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
|
||||
// writer so drafts look like every other template.
|
||||
|
||||
+53
-18
@@ -2,14 +2,16 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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{
|
||||
"defaultPorts": []any{"2456/udp"},
|
||||
"defaultPorts": []any{"30000/udp"},
|
||||
"requiredMemoryMb": json.Number("4096"),
|
||||
"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) {
|
||||
// Valheim's real first boot: its own files in ~/.config, plus Steam client logs (noise)
|
||||
valheim := &bootReport{ContainerChanges: []string{
|
||||
"A /home/steam/.config/unity3d/IronGate/Valheim/adminlist.txt",
|
||||
"A /home/steam/.config/unity3d/IronGate/Valheim/bannedlist.txt",
|
||||
"A /home/steam/Steam/logs/connection_log_2456.txt",
|
||||
// A real first boot: the game's own files in ~/.config, plus Steam client logs (noise)
|
||||
home := &bootReport{ContainerChanges: []string{
|
||||
"A /home/steam/.config/example-game/adminlist.txt",
|
||||
"A /home/steam/.config/example-game/bannedlist.txt",
|
||||
"A /home/steam/Steam/logs/connection_log_30000.txt",
|
||||
}}
|
||||
game := map[string]any{"defaultStartupCommand": "./valheim_server.x86_64 -nographics -batchmode -world X"}
|
||||
problems := savesOutsideVolumes(game, valheim)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/unity3d/IronGate/Valheim") || strings.Contains(problems[0], "Steam/logs") {
|
||||
game := map[string]any{"defaultStartupCommand": "./example_server -nographics -batchmode -world X"}
|
||||
problems := savesOutsideVolumes(game, home)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "/home/steam/.config/example-game") || strings.Contains(problems[0], "Steam/logs") {
|
||||
t.Fatalf("got %v", problems)
|
||||
}
|
||||
|
||||
// Fixed with a save-directory option under /server
|
||||
game["defaultStartupCommand"] = "./valheim_server.x86_64 -nographics -batchmode -world X -savedir /server/saves"
|
||||
if p := savesOutsideVolumes(game, valheim); p != nil {
|
||||
game["defaultStartupCommand"] = "./example_server -nographics -batchmode -world X -datadir /server/saves"
|
||||
if p := savesOutsideVolumes(game, home); p != nil {
|
||||
t.Errorf("redirected template still flagged: %v", p)
|
||||
}
|
||||
|
||||
// ARK's real first boot: only Proton's cache, which is not game data
|
||||
ark := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}}
|
||||
if p := savesOutsideVolumes(map[string]any{}, ark); p != nil {
|
||||
t.Errorf("ARK proton cache flagged: %v", p)
|
||||
// A Proton server's first boot: only Proton's cache, which is not game data
|
||||
proton := &bootReport{ContainerChanges: []string{"A /home/gameserver/.cache/umu-protonfixes/protonfixes_test.log"}}
|
||||
if p := savesOutsideVolumes(map[string]any{}, proton); p != nil {
|
||||
t.Errorf("proton cache flagged: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,10 +88,43 @@ func TestImageUnusable(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
|
||||
test := map[string]any{"settleSec": json.Number("120"), "secrets": map[string]any{"Password": "VALHEIM_TEST_PASSWORD"}}
|
||||
// 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": "EXAMPLE_TEST_PASSWORD"}}
|
||||
problems := strings.Join(draftCompleteness(map[string]any{}, test), "\n")
|
||||
if !strings.Contains(problems, "test.yaml has 'secrets' (Password)") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
@@ -75,7 +76,7 @@ func splitSettings(game map[string]any) []string {
|
||||
|
||||
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.
|
||||
func unwrapShell(cmd string) string {
|
||||
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))
|
||||
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
|
||||
}
|
||||
@@ -14,12 +14,12 @@ func textSettings(keys ...string) map[string]any {
|
||||
return map[string]any{"properties": props}
|
||||
}
|
||||
|
||||
func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) {
|
||||
// What the builder drafted for Valheim: quoted in the assignment, split by the unquoted $ARGS
|
||||
func TestSplitSettingsCatchesAnArgsString(t *testing.T) {
|
||||
// A real draft: quoted in the assignment, split by the unquoted $ARGS
|
||||
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"; ` +
|
||||
`HOME=/server ./valheim_server.x86_64 $ARGS`,
|
||||
`HOME=/server ./example_server $ARGS`,
|
||||
"configSchema": textSettings("ServerName", "WorldName", "Password"),
|
||||
}
|
||||
problems := splitSettings(game)
|
||||
@@ -36,8 +36,8 @@ func TestSplitSettingsCatchesTheValheimDraft(t *testing.T) {
|
||||
|
||||
func TestSplitSettingsAcceptsQuotedAndArrays(t *testing.T) {
|
||||
for _, startup := range []string{
|
||||
`./valheim_server.x86_64 -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 2456`,
|
||||
`args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./valheim_server.x86_64 "${args[@]}"`,
|
||||
`./example_server -name "${SERVER_NAME}" -world "$WORLD_NAME" -port 30000`,
|
||||
`args=(-name "$SERVER_NAME" -world "${WORLD_NAME:-Dedicated}"); [ -n "$PASSWORD" ] && args+=(-password "$PASSWORD"); ./example_server "${args[@]}"`,
|
||||
`bash -c 'exec ./server -name "$SERVER_NAME"'`,
|
||||
} {
|
||||
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) {
|
||||
game := map[string]any{
|
||||
"defaultPorts": []any{"2456/udp", "2457/udp"},
|
||||
"defaultPorts": []any{"30000/udp", "30001/udp"},
|
||||
"defaultConfig": map[string]any{
|
||||
"GamePort": json.Number("2456"),
|
||||
"GamePort": json.Number("30000"),
|
||||
"QueryPort": json.Number("27015"),
|
||||
"RCONPort": json.Number("25575"),
|
||||
"PublicPort": "{PUBLIC_PORT_2456}",
|
||||
"PublicPort": "{PUBLIC_PORT_30000}",
|
||||
"Transport": "udp",
|
||||
"Export": json.Number("9"),
|
||||
},
|
||||
@@ -86,7 +86,7 @@ func TestPortSettings(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -466,8 +466,8 @@ func (b *booter) copyOut(vol, mount string, files []string, report *bootReport)
|
||||
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.
|
||||
// fileLines reads the copied log files, so ready and error lines that never reach the console (servers
|
||||
// that only log to a file) are found too.
|
||||
func (b *booter) fileLines(report *bootReport) []logLine {
|
||||
var out []logLine
|
||||
for _, f := range report.FilesCreated {
|
||||
|
||||
@@ -6,21 +6,21 @@ import (
|
||||
)
|
||||
|
||||
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)
|
||||
// An Unreal-style option list
|
||||
list := parseConfig("/server/Game/Saved/Config/LinuxServer/GameSettings.ini", "[/Script/Game.WorldSettings]\n"+
|
||||
`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 Server", "CrossplayPlatforms": "(Steam,Xbox,PS5,Mac)"}
|
||||
if len(list) != len(want) {
|
||||
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 {
|
||||
t.Errorf("palworld setting %+v", s)
|
||||
t.Errorf("option list 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")
|
||||
// A server.properties file
|
||||
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 != "" {
|
||||
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: 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
|
||||
// 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: 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)
|
||||
@@ -90,13 +90,13 @@ func TestAnalyzeLines(t *testing.T) {
|
||||
|
||||
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,
|
||||
"server.properties": true,
|
||||
"Game/Saved/Config/LinuxServer/x.ini": true,
|
||||
"Game/Saved/Logs/Server.log": true,
|
||||
"steamapps/appmanifest_123.acf": false,
|
||||
"Engine/Config/BaseEngine.ini": false,
|
||||
"plugins/SomePlugin/config.yml": false,
|
||||
"world/level.dat": false,
|
||||
} {
|
||||
if got := isConfigCandidate(rel, 1000); got != want {
|
||||
t.Errorf("isConfigCandidate(%q) = %v, want %v", rel, got, want)
|
||||
|
||||
@@ -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
|
||||
// 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).
|
||||
// it (USER) or are built around an account whose home folder nobody else may enter (e.g. a home folder
|
||||
// at mode 700 owned by a uid other than 1000).
|
||||
func suggestContainerUser(f *imageFacts) {
|
||||
if u := strings.TrimSpace(f.User); u != "" && u != "root" && u != "0" {
|
||||
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 != "" {
|
||||
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{}
|
||||
for _, m := range defaultedRef.FindAllStringSubmatch(cmd, -1) {
|
||||
@@ -410,7 +410,7 @@ func containerPort(p string) string {
|
||||
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.
|
||||
func startupScriptPaths(game map[string]any) []string {
|
||||
cmd, _ := game["defaultStartupCommand"].(string)
|
||||
|
||||
@@ -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
|
||||
root:x:0:0:root:/root:/bin/bash
|
||||
gameserver:x:25000:25000::/home/gameserver:/bin/bash
|
||||
@@ -34,7 +34,7 @@ bash|/usr/bin/bash
|
||||
` + probeMarker + ` script /usr/bin/start_server
|
||||
#!/bin/bash
|
||||
cd /home/gameserver/steamcmd
|
||||
echo "$ASA_START_PARAMS ${ENABLE_DEBUG} $HOME"
|
||||
echo "$START_PARAMS ${ENABLE_DEBUG} $HOME"
|
||||
`
|
||||
|
||||
func arkFacts() *imageFacts {
|
||||
@@ -55,7 +55,7 @@ func TestProbeParsing(t *testing.T) {
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestProbeParsing(t *testing.T) {
|
||||
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)
|
||||
t.Errorf("suggested %q, want 25000:25000", f.SuggestedContainerUser)
|
||||
}
|
||||
|
||||
// uid 1000 images need nothing
|
||||
@@ -92,9 +92,9 @@ 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
|
||||
// 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
|
||||
"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)
|
||||
joined := strings.Join(problems, "\n")
|
||||
@@ -103,7 +103,7 @@ func TestCheckTemplate(t *testing.T) {
|
||||
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) {
|
||||
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)
|
||||
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 {
|
||||
t.Errorf("fixed template still has problems: %v", problems)
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ var researchSchema = map[string]any{
|
||||
|
||||
func runResearch(args []string) error {
|
||||
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...)")
|
||||
provider := fs.String("provider", "ollama", "ollama or gemini")
|
||||
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 " +
|
||||
"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 " +
|
||||
"Ascended), crossplay builds, or notably different server images. For each variant give:\n" +
|
||||
"the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " +
|
||||
"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 " +
|
||||
"plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " +
|
||||
"appear in the pages.\n" +
|
||||
@@ -250,13 +250,18 @@ func dedupeHits(hits []searchHit) []searchHit {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
text := string(raw)
|
||||
if len(text) > 9000 {
|
||||
text = text[:9000] + "\n...(truncated)"
|
||||
if len(text) > limit {
|
||||
text = text[:limit] + "\n...(truncated)"
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import "testing"
|
||||
|
||||
func TestPickPagesPrefersDocsAndOnePerSite(t *testing.T) {
|
||||
hits := []searchHit{
|
||||
{URL: "https://blog.example.com/valheim-server"},
|
||||
{URL: "https://github.com/a/valheim-docker"},
|
||||
{URL: "https://blog.example.com/example-server"},
|
||||
{URL: "https://github.com/a/example-docker"},
|
||||
{URL: "https://github.com/b/other"},
|
||||
{URL: "https://hub.docker.com/r/x/valheim"},
|
||||
{URL: "https://blog.example.com/valheim-server"},
|
||||
{URL: "https://hub.docker.com/r/x/example"},
|
||||
{URL: "https://blog.example.com/example-server"},
|
||||
}
|
||||
got := pickPages(hits, 3)
|
||||
if len(got) != 3 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -26,16 +26,16 @@ func TestTrimToJSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAppNameFromAppInfo(t *testing.T) {
|
||||
out := `"896660"
|
||||
out := `"1000000"
|
||||
{
|
||||
"common"
|
||||
{
|
||||
"name" "Valheim Dedicated Server"
|
||||
"name" "Example Dedicated Server"
|
||||
"type" "Tool"
|
||||
}
|
||||
}`
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
"defaultDockerImage": { "description": "Image the game server runs in.", "type": "string", "minLength": 1 },
|
||||
"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"],
|
||||
"pattern": "^[1-9][0-9]{0,8}:[1-9][0-9]{0,8}$"
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user