Compare commits
2
Commits
8623ba2d7b
...
929cbc31cf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929cbc31cf | ||
|
|
8b49895d5e |
No files matched your search
+7
-4
@@ -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
@@ -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/`.
|
||||||
@@ -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
|
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()
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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}$"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user