From 5fb7736be2fb75ade62a228d5ea7ea98b32e8198 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 17:12:50 -0400 Subject: [PATCH 1/6] ci: publish templates to the control panel on main Adds `gbrgames export`, which builds the games_published payload from every game.yaml, and a CI step that posts it to the panel webhook after validation on main. Unlike the node-agent notify, a failed publish fails the run so stale templates in the panel never go unnoticed. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/validate.yml | 23 ++++++++++++++ cmd/gbrgames/export.go | 58 +++++++++++++++++++++++++++++++++++ cmd/gbrgames/main.go | 5 ++- 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 cmd/gbrgames/export.go diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index ecc4314..5257242 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -7,6 +7,9 @@ on: push: workflow_dispatch: +env: + PANEL_WEBHOOK_URL: https://api.gbrhosting.com/api/v1/webhooks/gitea + jobs: validate: runs-on: ubuntu-latest @@ -21,3 +24,23 @@ jobs: - name: Validate run: go run ./cmd/gbrgames validate + + # main is the source of truth: send every template to the panel, which upserts them + # (new games arrive disabled). Unlike the node-agent notify, a failure here fails the run, + # because the panel would otherwise keep serving the previous templates silently. + - name: Publish to Control Panel + if: github.ref == 'refs/heads/main' + env: + PANEL_WEBHOOK_SECRET: ${{ secrets.PANEL_WEBHOOK_SECRET }} + run: | + if [ -z "$PANEL_WEBHOOK_SECRET" ]; then + echo "::error::PANEL_WEBHOOK_SECRET is not set; templates were not published" + exit 1 + fi + go run ./cmd/gbrgames export -repo "${{ github.repository }}" -commit "${{ github.sha }}" -out payload.json + curl -sS --fail-with-body --max-time 30 --retry 3 --retry-delay 10 \ + -X POST "${{ env.PANEL_WEBHOOK_URL }}" \ + -H "Content-Type: application/json" \ + -H "X-Gitea-Token: $PANEL_WEBHOOK_SECRET" \ + -H "X-GBR-Event: games_published" \ + --data-binary @payload.json diff --git a/cmd/gbrgames/export.go b/cmd/gbrgames/export.go new file mode 100644 index 0000000..e5f8f27 --- /dev/null +++ b/cmd/gbrgames/export.go @@ -0,0 +1,58 @@ +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "path/filepath" +) + +// runExport writes the games_published webhook payload: every game.yaml as JSON, ready for the panel. +func runExport(args []string) error { + fs := flag.NewFlagSet("export", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + repo := fs.String("repo", "", "repository full name, e.g. gbreed-public/gbr-games") + commit := fs.String("commit", "", "commit SHA the templates come from") + out := fs.String("out", "", "file to write (default stdout)") + fs.Parse(args) + + entries, err := os.ReadDir(*gamesDir) + if err != nil { + return err + } + games := []any{} + for _, e := range entries { + if !e.IsDir() { + continue + } + raw, err := os.ReadFile(filepath.Join(*gamesDir, e.Name(), "game.yaml")) + if err != nil { + return fmt.Errorf("%s: %w", e.Name(), err) + } + game, err := loadYAML(raw) + if err != nil { + return fmt.Errorf("%s/game.yaml: %w", e.Name(), err) + } + games = append(games, game) + } + if len(games) == 0 { + return errors.New("no games to export") + } + + payload := map[string]any{ + "repository": map[string]any{"full_name": *repo}, + "after": *commit, + "games": games, + } + b, err := json.Marshal(payload) + if err != nil { + return err + } + if *out == "" { + _, err = os.Stdout.Write(b) + return err + } + return os.WriteFile(*out, b, 0o644) +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 41c23b9..3ebca68 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -2,6 +2,7 @@ // // go run ./cmd/gbrgames validate # check every games//game.yaml (+ test.yaml) // go run ./cmd/gbrgames import export.json # write games// from GET /api/v1/admin/games +// go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel package main import ( @@ -19,6 +20,8 @@ func main() { err = runValidate(os.Args[2:]) case "import": err = runImport(os.Args[2:]) + case "export": + err = runExport(os.Args[2:]) default: usage() } @@ -29,6 +32,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE]") os.Exit(2) } From e9ff989246cebd0324832c5f8823124c370dc038 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 17:49:19 -0400 Subject: [PATCH 2/6] ci: boot-test changed games before publishing Only games whose folder changed are boot-tested (against the previous main commit, or against main on branches; manual runs pick games). Each one is deployed by the node-agent's real deploy code on a dedicated game-test runner, in a network that can reach the internet but not the LAN. Publishing to the panel runs only if they all booted. - boottest-payload builds the DEPLOY_SERVER message the panel would send - readyPattern/failPatterns in game.yaml; readyExample in test.yaml is checked against the pattern during validation - secrets in test.yaml come from CI; a game missing one is skipped - Minecraft gets a ready pattern; Dragonwilds needs DRAGONWILDS_OWNER_ID - RUNNER.md + scripts/runner: runner, isolated network, systemd unit Also fixes newline values ("\n" separators/footers) that yaml.v3 wrote as "" on import, which would have broken ARK and Dragonwilds config files once synced, and adds a round-trip test for the importer. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/validate.yml | 83 ++++++++- RUNNER.md | 55 ++++++ cmd/gbrgames/boottest.go | 162 ++++++++++++++++++ cmd/gbrgames/import.go | 50 +++++- cmd/gbrgames/import_test.go | 48 ++++++ cmd/gbrgames/main.go | 5 +- cmd/gbrgames/validate.go | 19 ++ .../ark-survival-ascended-crossplay/game.yaml | 2 +- games/minecraft-crossplay/game.yaml | 5 +- games/minecraft-crossplay/test.yaml | 1 + .../game.yaml | 4 +- .../test.yaml | 4 + games/satisfactory-vanilla-stable/game.yaml | 2 +- schema/game.schema.json | 9 + schema/test.schema.json | 10 ++ scripts/boot-test.sh | 50 ++++++ scripts/changed-games.sh | 24 +++ scripts/runner/config.yaml | 10 ++ scripts/runner/docker-compose.yml | 17 ++ scripts/runner/gbr-boottest-network.service | 13 ++ scripts/runner/setup-boottest-network.sh | 30 ++++ 21 files changed, 585 insertions(+), 18 deletions(-) create mode 100644 RUNNER.md create mode 100644 cmd/gbrgames/boottest.go create mode 100644 cmd/gbrgames/import_test.go create mode 100755 scripts/boot-test.sh create mode 100755 scripts/changed-games.sh create mode 100644 scripts/runner/config.yaml create mode 100644 scripts/runner/docker-compose.yml create mode 100644 scripts/runner/gbr-boottest-network.service create mode 100755 scripts/runner/setup-boottest-network.sh diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml index 5257242..cc20b78 100644 --- a/.gitea/workflows/validate.yml +++ b/.gitea/workflows/validate.yml @@ -1,11 +1,16 @@ name: Validate game templates # Push-only on purpose: this repo is public, and a pull_request trigger would run a fork's -# version of this workflow on our self-hosted runner. PRs from branches in this repo are +# version of this workflow on our self-hosted runners. PRs from branches in this repo are # still checked through their push. on: push: workflow_dispatch: + inputs: + games: + description: "Games to boot-test: comma-separated ids, or 'all'" + required: true + default: all env: PANEL_WEBHOOK_URL: https://api.gbrhosting.com/api/v1/webhooks/gitea @@ -13,6 +18,73 @@ env: jobs: validate: runs-on: ubuntu-latest + outputs: + games: ${{ steps.changed.outputs.games }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Validate + run: | + go test ./... + go run ./cmd/gbrgames validate + + # main: games changed since the previous main commit. Branches: games changed against main, + # so results show up before merging. Manual runs pick their own. + - name: Find changed games + id: changed + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + games=$(scripts/changed-games.sh "${{ inputs.games }}") + elif [ "${{ github.ref }}" = "refs/heads/main" ]; then + before="${{ github.event.before }}" + if [ -z "$before" ] || ! git cat-file -e "$before" 2>/dev/null; then + games=$(scripts/changed-games.sh all) + else + games=$(scripts/changed-games.sh "$before" "${{ github.sha }}") + fi + else + games=$(scripts/changed-games.sh "$(git merge-base origin/main HEAD)" HEAD) + fi + echo "Games to boot-test: ${games:-none}" + echo "games=$(echo $games)" >> "$GITHUB_OUTPUT" + + boot-test: + needs: validate + if: needs.validate.outputs.games != '' + # A dedicated runner on VM 116 whose game containers can reach the internet but not the LAN + runs-on: game-test + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + # Secrets named in a game's test.yaml must be listed here to reach the boot test + - name: Boot test changed games + env: + DRAGONWILDS_OWNER_ID: ${{ secrets.DRAGONWILDS_OWNER_ID }} + run: scripts/boot-test.sh ${{ needs.validate.outputs.games }} + + # main is the source of truth: send every template to the panel, which saves the changed ones + # (new games arrive disabled). Runs only if the changed games booted, or none changed. + publish: + needs: [validate, boot-test] + if: >- + always() && github.ref == 'refs/heads/main' && github.event_name == 'push' + && needs.validate.result == 'success' + && (needs.boot-test.result == 'success' || needs.boot-test.result == 'skipped') + runs-on: ubuntu-latest steps: - name: Checkout Code uses: actions/checkout@v4 @@ -22,14 +94,9 @@ jobs: with: go-version-file: go.mod - - name: Validate - run: go run ./cmd/gbrgames validate - - # main is the source of truth: send every template to the panel, which upserts them - # (new games arrive disabled). Unlike the node-agent notify, a failure here fails the run, - # because the panel would otherwise keep serving the previous templates silently. + # Unlike the node-agent notify, a failure here fails the run, because the panel would + # otherwise keep serving the previous templates silently. - name: Publish to Control Panel - if: github.ref == 'refs/heads/main' env: PANEL_WEBHOOK_SECRET: ${{ secrets.PANEL_WEBHOOK_SECRET }} run: | diff --git a/RUNNER.md b/RUNNER.md new file mode 100644 index 0000000..919a1b9 --- /dev/null +++ b/RUNNER.md @@ -0,0 +1,55 @@ +# Boot-test runner setup + +Boot tests run on a dedicated Gitea Actions runner labelled `game-test`. It deploys each changed game +with the node-agent's real deploy code, inside a Docker network that can reach the internet (Steam, +Mojang, plugin downloads) but **not** private networks or the host. `scripts/boot-test.sh` refuses to +run if that network is missing, so a half-configured runner can't boot servers on the LAN. + +Everything here runs on the runner VM as root. + +## 1. Isolated network + +```bash +mkdir -p /opt/gbr-boottest +cp scripts/runner/setup-boottest-network.sh /opt/gbr-boottest/ +chmod +x /opt/gbr-boottest/setup-boottest-network.sh +cp scripts/runner/gbr-boottest-network.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now gbr-boottest-network +``` + +Check it from a throwaway container: the first command should print an IP, the second should time out. + +```bash +docker run --rm --network gbr-boottest curlimages/curl -s --max-time 5 https://ifconfig.me +docker run --rm --network gbr-boottest curlimages/curl -s --max-time 5 http://192.168.1.1 || echo blocked +``` + +## 2. The runner + +1. In Gitea: **gbreed-public → Settings → Actions → Runners → Create new runner**, and copy the token. +2. On the VM: + +```bash +mkdir -p /opt/gitea-game-test-runner && cd /opt/gitea-game-test-runner +cp /scripts/runner/docker-compose.yml /scripts/runner/config.yaml . +echo "GITEA_RUNNER_REGISTRATION_TOKEN=" > .env +docker compose up -d +``` + +The runner shows up as `game-test` in the org's runner list. It takes one job at a time; the build +runner keeps handling everything else. + +## 3. Secrets + +Games whose `test.yaml` maps a value under `secrets` are skipped until that Actions secret exists in +the org or repo, and the workflow passes it through (see the `env:` of the boot-test step). + +| Secret | Game | What | +| --- | --- | --- | +| `DRAGONWILDS_OWNER_ID` | RuneScape: Dragonwilds | A Player ID; the server won't start without one | + +## Housekeeping + +Game images and SteamCMD downloads add up. A weekly `docker system prune -af --filter "until=168h"` +(cron or a systemd timer) keeps the disk in check; each test removes its own container and volume. diff --git a/cmd/gbrgames/boottest.go b/cmd/gbrgames/boottest.go new file mode 100644 index 0000000..6477bca --- /dev/null +++ b/cmd/gbrgames/boottest.go @@ -0,0 +1,162 @@ +package main + +import ( + "crypto/rand" + "encoding/json" + "errors" + "flag" + "fmt" + "math/big" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Exit code for "this game needs a secret CI doesn't have": the workflow skips it instead of failing. +const exitMissingSecret = 3 + +const bootTestNetwork = "gbr-boottest" + +var publicPort = regexp.MustCompile(`\{PUBLIC_PORT_(\d+)\}`) +var savedValue = regexp.MustCompile(`\{saved:[A-Za-z0-9_.-]+\}`) + +// runBootTestPayload writes the DEPLOY_SERVER message the panel would send for this game, plus the +// boot-test settings the node-agent's BootTest reads. Secrets come from environment variables named +// in test.yaml's 'secrets'. +func runBootTestPayload(args []string) error { + fs := flag.NewFlagSet("boottest-payload", flag.ExitOnError) + gamesDir := fs.String("games", "games", "directory holding one folder per game") + out := fs.String("out", "", "file to write (default stdout)") + fs.Parse(args) + if fs.NArg() != 1 { + return errors.New("boottest-payload needs a game id") + } + id := fs.Arg(0) + dir := filepath.Join(*gamesDir, id) + + g, err := loadMap(filepath.Join(dir, "game.yaml")) + if err != nil { + return err + } + test := map[string]any{} + if _, err := os.Stat(filepath.Join(dir, "test.yaml")); err == nil { + if test, err = loadMap(filepath.Join(dir, "test.yaml")); err != nil { + return err + } + } + + config := map[string]any{} + if d, ok := g["defaultConfig"].(map[string]any); ok { + for k, v := range d { + config[k] = v + } + } + if c, ok := test["config"].(map[string]any); ok { + for k, v := range c { + config[k] = v + } + } + if s, ok := test["secrets"].(map[string]any); ok { + for key, name := range s { + value, ok := os.LookupEnv(fmt.Sprint(name)) + if !ok || value == "" { + fmt.Fprintf(os.Stderr, "skip: %s needs secret %s for %s\n", id, name, key) + os.Exit(exitMissingSecret) + } + config[key] = value + } + } + // Like the panel fills blank x-generate fields on deploy; other blank passwords (which an owner + // would type in, and some games refuse to start without) get a throwaway value too + for key, p := range schemaProperties(g) { + prop, _ := p.(map[string]any) + if prop["x-generate"] != nil || sensitiveKey.MatchString(key) { + if s, _ := config[key].(string); s == "" { + config[key] = "boot-" + randomSuffix() + } + } + } + for k, v := range config { + if s, ok := v.(string); ok { + config[k] = resolvePlaceholders(s) + } + } + + timeout, settle := 900, 120 + if g["installMethod"] == "STEAMCMD" { + timeout = 2400 + } + if v, ok := test["timeoutSec"].(json.Number); ok { + if n, err := v.Int64(); err == nil { + timeout = int(n) + } + } + if v, ok := test["settleSec"].(json.Number); ok { + if n, err := v.Int64(); err == nil { + settle = int(n) + } + } + + payload := map[string]any{ + "command": "DEPLOY_SERVER", + "serverId": "boottest-" + id, + "containerName": "boottest-" + id, + "dockerImage": g["defaultDockerImage"], + "gameConfig": config, + // Readiness is read from logs, so nothing is published on the runner + "ports": []any{}, + "installMethod": g["installMethod"], + "installConfig": g["installConfig"], + "configFilePath": g["configFilePath"], + "configFileFormat": g["configFileFormat"], + "configFormatOptions": g["configFormatOptions"], + "startupCommand": g["defaultStartupCommand"], + "memoryLimitMb": g["requiredMemoryMb"], + "dockerNetwork": bootTestNetwork, + "logFilePath": g["logFilePath"], + "readyPattern": g["readyPattern"], + "failPatterns": g["failPatterns"], + "timeoutSec": timeout, + "settleSec": settle, + } + b, err := json.MarshalIndent(payload, "", " ") + if err != nil { + return err + } + if *out == "" { + _, err = os.Stdout.Write(b) + return err + } + return os.WriteFile(*out, b, 0o644) +} + +// resolvePlaceholders stands in for what the panel fills per server; saved values have no owner here. +func resolvePlaceholders(s string) string { + s = strings.NewReplacer("{SERVER_NAME}", "gbr-boot-test", "{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(s) + s = publicPort.ReplaceAllString(s, "$1") + s = savedValue.ReplaceAllString(s, "") + // The panel strips line breaks from config values before they reach a node + return strings.NewReplacer("\r", "", "\n", "").Replace(s) +} + +func loadMap(path string) (map[string]any, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, err + } + v, err := loadYAML(raw) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + m, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s: not a mapping", path) + } + return m, nil +} + +func randomSuffix() string { + n, _ := rand.Int(rand.Reader, big.NewInt(1_000_000)) + return fmt.Sprintf("%06d", n.Int64()) +} diff --git a/cmd/gbrgames/import.go b/cmd/gbrgames/import.go index d6cd789..5ad9848 100644 --- a/cmd/gbrgames/import.go +++ b/cmd/gbrgames/import.go @@ -8,6 +8,7 @@ import ( "math" "os" "path/filepath" + "sort" "strings" "gopkg.in/yaml.v3" @@ -21,7 +22,7 @@ var gameFields = []string{ "installMethod", "installConfig", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "agnosticCommandConfig", "clusterConfig", - "logFilePath", "backupPaths", "maxBackupSizeMb", + "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb", "publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", "aiCoverPrompt", } @@ -82,11 +83,11 @@ func writeGame(path string, g map[string]any) error { if !ok || isEmpty(v) { continue } - var val yaml.Node - if err := val.Encode(normalize(v)); err != nil { + val, err := toNode(normalize(v)) + if err != nil { return err } - doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, &val) + doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val) } return writeYAML(path, gameHeader, doc) } @@ -128,6 +129,47 @@ func writeYAML(path, header string, doc *yaml.Node) error { return os.WriteFile(path, []byte(b.String()), 0o644) } +// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of +// whitespace, like a "\n" config separator, into "" and the value is silently lost. +func toNode(v any) (*yaml.Node, error) { + switch x := v.(type) { + case map[string]any: + n := &yaml.Node{Kind: yaml.MappingNode} + keys := make([]string, 0, len(x)) + for k := range x { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + val, err := toNode(x[k]) + if err != nil { + return nil, err + } + n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val) + } + return n, nil + case []any: + n := &yaml.Node{Kind: yaml.SequenceNode} + for _, e := range x { + val, err := toNode(e) + if err != nil { + return nil, err + } + n.Content = append(n.Content, val) + } + return n, nil + case string: + if x != "" && strings.TrimSpace(x) == "" { + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil + } + } + n := &yaml.Node{} + if err := n.Encode(v); err != nil { + return nil, err + } + return n, nil +} + // normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0. func normalize(v any) any { switch x := v.(type) { diff --git a/cmd/gbrgames/import_test.go b/cmd/gbrgames/import_test.go new file mode 100644 index 0000000..2d0d286 --- /dev/null +++ b/cmd/gbrgames/import_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// Whatever the panel exports must come back unchanged after import and export. +func TestImportRoundTrip(t *testing.T) { + game := map[string]any{ + "id": "round-trip", "name": "Round Trip", "defaultDockerImage": "debian:12", "installMethod": "NONE", + "defaultStartupCommand": "./start.sh -log", + "requiredMemoryMb": float64(4096), + "configFormatOptions": map[string]any{ + "separator": "\n", "footer": "\n", "header": "[Section]\n", "assignment": "=", "space": " ", + "multi": "[A]\nOptionSettings=(", "quoteStrings": true, + }, + "installConfig": map[string]any{"script": "#!/bin/bash\nset -e\necho hi\n"}, + "defaultConfig": map[string]any{"Rate": "1.000000", "Port": float64(7777), "Empty": "", "Ratio": 0.5}, + } + + dir := t.TempDir() + path := filepath.Join(dir, "game.yaml") + if err := writeGame(path, game); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + loaded, err := loadYAML(raw) + if err != nil { + t.Fatal(err) + } + + // Compare as JSON, the way the panel receives it + var got, want any + gotJSON, _ := json.Marshal(loaded) + wantJSON, _ := json.Marshal(game) + json.Unmarshal(gotJSON, &got) + json.Unmarshal(wantJSON, &want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("round trip changed the template\n got: %s\nwant: %s\nyaml:\n%s", gotJSON, wantJSON, raw) + } +} diff --git a/cmd/gbrgames/main.go b/cmd/gbrgames/main.go index 3ebca68..3505b7c 100644 --- a/cmd/gbrgames/main.go +++ b/cmd/gbrgames/main.go @@ -3,6 +3,7 @@ // go run ./cmd/gbrgames validate # check every games//game.yaml (+ test.yaml) // go run ./cmd/gbrgames import export.json # write games// from GET /api/v1/admin/games // go run ./cmd/gbrgames export -out p.json # build the games_published payload CI sends to the panel +// go run ./cmd/gbrgames boottest-payload ID # build the deploy message the node-agent's BootTest runs package main import ( @@ -22,6 +23,8 @@ func main() { err = runImport(os.Args[2:]) case "export": err = runExport(os.Args[2:]) + case "boottest-payload": + err = runBootTestPayload(os.Args[2:]) default: usage() } @@ -32,6 +35,6 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE]") + fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID") os.Exit(2) } diff --git a/cmd/gbrgames/validate.go b/cmd/gbrgames/validate.go index 4720e11..394fce3 100644 --- a/cmd/gbrgames/validate.go +++ b/cmd/gbrgames/validate.go @@ -94,6 +94,17 @@ func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema) } scanPrivateIPs(raw, "game.yaml", add) + // Go's regexp is a subset of Java's, which the node-agent uses; anything that compiles here works there + patterns, _ := g["failPatterns"].([]any) + if p, ok := g["readyPattern"].(string); ok { + patterns = append(patterns, p) + } + for _, p := range patterns { + if _, err := regexp.Compile(fmt.Sprint(p)); err != nil { + add("pattern %q does not compile: %v", p, err) + } + } + defaults, _ := g["defaultConfig"].(map[string]any) props := schemaProperties(g) known := map[string]bool{} @@ -165,6 +176,14 @@ func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema) add("test.yaml secrets.%s is not a key of this game", key) } } + if example, ok := t["readyExample"].(string); ok { + pattern, _ := g["readyPattern"].(string) + if re, err := regexp.Compile(pattern); pattern == "" || err != nil { + add("test.yaml has a readyExample but the game has no valid readyPattern") + } else if !re.MatchString(example) { + add("readyPattern %q does not match test.yaml readyExample %q", pattern, example) + } + } return problems } diff --git a/games/ark-survival-ascended-crossplay/game.yaml b/games/ark-survival-ascended-crossplay/game.yaml index 0b44b81..e34b705 100644 --- a/games/ark-survival-ascended-crossplay/game.yaml +++ b/games/ark-survival-ascended-crossplay/game.yaml @@ -282,7 +282,7 @@ configFormatOptions: footer: null header: '[ServerSettings]' quoteStrings: false - separator: "" + separator: "\n" trueValue: "True" unquotedKeys: [] uppercaseKeys: false diff --git a/games/minecraft-crossplay/game.yaml b/games/minecraft-crossplay/game.yaml index aeb3995..cc173ba 100644 --- a/games/minecraft-crossplay/game.yaml +++ b/games/minecraft-crossplay/game.yaml @@ -239,7 +239,7 @@ configFormatOptions: footer: "" header: "" quoteStrings: false - separator: "" + separator: "\n" trueValue: "true" unquotedKeys: [] uppercaseKeys: false @@ -376,6 +376,9 @@ agnosticCommandConfig: restPasswordKey: null restPortKey: null restUser: null +readyPattern: 'Done \([0-9.]+s\)! For help' +failPatterns: + - You need to agree to the EULA backupPaths: - world - world_nether diff --git a/games/minecraft-crossplay/test.yaml b/games/minecraft-crossplay/test.yaml index 430f73c..04dc007 100644 --- a/games/minecraft-crossplay/test.yaml +++ b/games/minecraft-crossplay/test.yaml @@ -2,4 +2,5 @@ # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. config: EULA: true +readyExample: '[21:37:42 INFO]: Done (16.390s)! For help, type "help"' timeoutSec: 900 diff --git a/games/runescape-dragonwilds-native-linux/game.yaml b/games/runescape-dragonwilds-native-linux/game.yaml index ee11c7b..78492c0 100644 --- a/games/runescape-dragonwilds-native-linux/game.yaml +++ b/games/runescape-dragonwilds-native-linux/game.yaml @@ -88,11 +88,11 @@ configFileFormat: STANDARD configFormatOptions: assignment: = falseValue: "False" - footer: "" + footer: "\n" header: | [/Script/Dominion.DedicatedServerSettings] quoteStrings: false - separator: "" + separator: "\n" trueValue: "True" uppercaseKeys: false agnosticCommandConfig: diff --git a/games/runescape-dragonwilds-native-linux/test.yaml b/games/runescape-dragonwilds-native-linux/test.yaml index d8b0933..32617f4 100644 --- a/games/runescape-dragonwilds-native-linux/test.yaml +++ b/games/runescape-dragonwilds-native-linux/test.yaml @@ -1,3 +1,7 @@ # yaml-language-server: $schema=../../schema/test.schema.json # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. +# The server refuses to start without a real Player ID, so this test is skipped until the +# DRAGONWILDS_OWNER_ID Actions secret exists. +secrets: + OwnerId: DRAGONWILDS_OWNER_ID timeoutSec: 2400 diff --git a/games/satisfactory-vanilla-stable/game.yaml b/games/satisfactory-vanilla-stable/game.yaml index 03e88f4..ebbd6a0 100644 --- a/games/satisfactory-vanilla-stable/game.yaml +++ b/games/satisfactory-vanilla-stable/game.yaml @@ -155,7 +155,7 @@ configFormatOptions: footer: "" header: "" quoteStrings: false - separator: "" + separator: "\n" trueValue: "true" unquotedKeys: [] uppercaseKeys: false diff --git a/schema/game.schema.json b/schema/game.schema.json index fd09fef..1287166 100644 --- a/schema/game.schema.json +++ b/schema/game.schema.json @@ -51,6 +51,15 @@ "clusterConfig": { "type": ["object", "null"] }, "logFilePath": { "type": ["string", "null"] }, + "readyPattern": { + "description": "Regex matched against the server's console output and log file once it can accept players. Without one, boot tests only check the server stays up.", + "type": ["string", "null"] + }, + "failPatterns": { + "description": "Regexes that mean the server will not come up (e.g. an unaccepted EULA). Boot tests fail as soon as one matches.", + "type": ["array", "null"], + "items": { "type": "string" } + }, "backupPaths": { "type": ["array", "null"], "items": { "type": "string" } }, "maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 }, diff --git a/schema/test.schema.json b/schema/test.schema.json index 631fea0..90bb70b 100644 --- a/schema/test.schema.json +++ b/schema/test.schema.json @@ -22,6 +22,16 @@ "type": "integer", "minimum": 60, "maximum": 7200 + }, + "readyExample": { + "description": "A real console line from a server that is ready. Validation checks the game's readyPattern matches it, so a broken pattern fails in seconds instead of timing out a boot test.", + "type": "string" + }, + "settleSec": { + "description": "For games without a readyPattern: how long the server must stay up after starting to pass.", + "type": "integer", + "minimum": 10, + "maximum": 3600 } } } diff --git a/scripts/boot-test.sh b/scripts/boot-test.sh new file mode 100755 index 0000000..0d50956 --- /dev/null +++ b/scripts/boot-test.sh @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# Boot-tests the given games with the node-agent's real deploy code, one after another. +# Each game is deployed on this machine's Docker daemon inside the isolated gbr-boottest network and +# must reach its readyPattern (or stay up) before its timeout. Games needing a secret CI doesn't +# have are skipped. Exits non-zero if any game failed. +# boot-test.sh palworld minecraft-crossplay +set -uo pipefail + +AGENT_IMAGE="${AGENT_IMAGE:-www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest}" +NETWORK=gbr-boottest + +if ! docker network inspect "$NETWORK" > /dev/null 2>&1; then + # Created by the runner setup with LAN access blocked; never fall back to an unisolated network + echo "::error::Docker network $NETWORK is missing on this runner; see RUNNER.md" + exit 1 +fi +docker pull -q "$AGENT_IMAGE" > /dev/null + +# Built once: "go run" would turn the skip exit code (3) into a plain failure +gbrgames="${RUNNER_TEMP:-/tmp}/gbrgames" +go build -o "$gbrgames" ./cmd/gbrgames || exit 1 + +passed=() failed=() skipped=() +for id in "$@"; do + echo "::group::boot test: $id" + payload=$("$gbrgames" boottest-payload "$id") + code=$? + if [ $code -eq 3 ]; then + skipped+=("$id") + echo "::endgroup::" + continue + elif [ $code -ne 0 ]; then + failed+=("$id") + echo "::endgroup::" + continue + fi + + docker run --rm -i \ + -v /var/run/docker.sock:/var/run/docker.sock \ + --entrypoint java "$AGENT_IMAGE" \ + -Dloader.main=com.gbreed.gbr.nodeagent.BootTest -cp app.jar \ + org.springframework.boot.loader.launch.PropertiesLauncher <<< "$payload" + if [ $? -eq 0 ]; then passed+=("$id"); else failed+=("$id"); fi + echo "::endgroup::" +done + +echo "passed: ${passed[*]:-none}" +echo "skipped: ${skipped[*]:-none} (missing secrets)" +echo "failed: ${failed[*]:-none}" +[ ${#failed[@]} -eq 0 ] diff --git a/scripts/changed-games.sh b/scripts/changed-games.sh new file mode 100755 index 0000000..788cb69 --- /dev/null +++ b/scripts/changed-games.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Prints the ids of games whose folder changed, one per line. +# changed-games.sh BASE_REF HEAD_REF games changed between two commits +# changed-games.sh all every game +# changed-games.sh "palworld,ark-..." just these +set -euo pipefail + +if [ "${1:-}" = "all" ]; then + ls games + exit 0 +fi +if [ $# -eq 1 ]; then + tr ',' '\n' <<< "$1" | sed 's/ //g' | grep -v '^$' | while read -r id; do + [ -d "games/$id" ] && echo "$id" || echo "unknown game: $id" >&2 + done + exit 0 +fi + +git diff --name-only "$1" "$2" -- games/ \ + | cut -d/ -f2 | sort -u \ + | while read -r id; do + # A deleted game has nothing to boot + [ -f "games/$id/game.yaml" ] && echo "$id" + done || true diff --git a/scripts/runner/config.yaml b/scripts/runner/config.yaml new file mode 100644 index 0000000..1f0e408 --- /dev/null +++ b/scripts/runner/config.yaml @@ -0,0 +1,10 @@ +runner: + # One boot test at a time: game servers are big, and tests must not fight over RAM + capacity: 1 + timeout: 3h + +container: + # Empty = use the host's Docker and mount it into job containers, which the boot test needs + # to start the node-agent and game containers + docker_host: "" + force_pull: false diff --git a/scripts/runner/docker-compose.yml b/scripts/runner/docker-compose.yml new file mode 100644 index 0000000..d985921 --- /dev/null +++ b/scripts/runner/docker-compose.yml @@ -0,0 +1,17 @@ +# The game-test runner: a second act_runner next to the build runner, so long boot tests +# (ARK installs take 20+ minutes) never hold up builds. One job at a time. +services: + game-test-runner: + image: gitea/act_runner:0.6.1 + container_name: gitea-game-test-runner + restart: unless-stopped + environment: + GITEA_INSTANCE_URL: https://www.gitea.gbreed.com + GITEA_RUNNER_REGISTRATION_TOKEN: ${GITEA_RUNNER_REGISTRATION_TOKEN} + GITEA_RUNNER_NAME: game-test + GITEA_RUNNER_LABELS: game-test:docker://catthehacker/ubuntu:act-latest + CONFIG_FILE: /config.yaml + volumes: + - ./config.yaml:/config.yaml:ro + - ./data:/data + - /var/run/docker.sock:/var/run/docker.sock diff --git a/scripts/runner/gbr-boottest-network.service b/scripts/runner/gbr-boottest-network.service new file mode 100644 index 0000000..53a2a08 --- /dev/null +++ b/scripts/runner/gbr-boottest-network.service @@ -0,0 +1,13 @@ +[Unit] +Description=Isolated Docker network for GBR game boot tests +After=docker.service +Requires=docker.service +PartOf=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/opt/gbr-boottest/setup-boottest-network.sh + +[Install] +WantedBy=docker.service diff --git a/scripts/runner/setup-boottest-network.sh b/scripts/runner/setup-boottest-network.sh new file mode 100755 index 0000000..5d7174c --- /dev/null +++ b/scripts/runner/setup-boottest-network.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Creates the gbr-boottest Docker network and blocks it from private networks, so boot-tested game +# servers can download what they need from the internet but never reach the LAN or the host. +# Safe to re-run; the systemd unit in this folder runs it after every Docker start. +set -euo pipefail + +NETWORK=gbr-boottest +SUBNET=172.30.99.0/24 + +if ! docker network inspect "$NETWORK" > /dev/null 2>&1; then + docker network create --driver bridge --subnet "$SUBNET" \ + -o com.docker.network.bridge.name=br-gbrboot "$NETWORK" +fi + +# DOCKER-USER is evaluated before Docker's own forwarding rules. Rebuild our rules from scratch. +iptables -N GBR-BOOTTEST 2>/dev/null || iptables -F GBR-BOOTTEST +iptables -C DOCKER-USER -s "$SUBNET" -j GBR-BOOTTEST 2>/dev/null \ + || iptables -I DOCKER-USER -s "$SUBNET" -j GBR-BOOTTEST + +# Replies to connections the container opened (downloads) are fine +iptables -A GBR-BOOTTEST -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN +for net in 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16; do + iptables -A GBR-BOOTTEST -d "$net" -j DROP +done +iptables -A GBR-BOOTTEST -j RETURN + +# The host itself (SSH, the Docker API, anything listening) is reached via INPUT, not FORWARD +iptables -C INPUT -i br-gbrboot -j DROP 2>/dev/null || iptables -I INPUT -i br-gbrboot -j DROP + +echo "gbr-boottest ready: $SUBNET, private networks and host blocked" From e8edf853f7287d596b0f1327c35b845714aceecc Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 20:36:24 -0400 Subject: [PATCH 3/6] fix(minecraft): 3G heap so the server fits its 4 GB minimum With MEMORY=4G and requiredMemoryMb 4096, Java's heap plus overhead exceeded the container limit and the server was killed on start; the boot test failed every time. With 3G it is ready in about 18s at 4 GB. Co-Authored-By: Claude Opus 5.5 --- games/minecraft-crossplay/game.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/games/minecraft-crossplay/game.yaml b/games/minecraft-crossplay/game.yaml index cc173ba..5b0fdbd 100644 --- a/games/minecraft-crossplay/game.yaml +++ b/games/minecraft-crossplay/game.yaml @@ -31,7 +31,7 @@ defaultConfig: HARDCORE: false JVM_OPTS: -DgeyserTransport=nethernet -DgeyserAdvertiseAddresses={PUBLIC_IP}:{PUBLIC_PORT_19132} MAX_PLAYERS: 20 - MEMORY: 4G + MEMORY: 3G MODE: survival MOTD: GBRHosting - {SERVER_NAME} ONLINE_MODE: true @@ -117,8 +117,8 @@ configSchema: type: integer MEMORY: category: System & General - default: 4G - description: Maximum heap memory allocated to the Java process (e.g. 4G, 6G, 8G). + default: 3G + description: Java heap for the server. Keep it about 1 GB below your server's memory (3G on 4 GB, 7G on 8 GB); Java needs the rest, and a heap as large as the memory limit makes the server crash on start. title: Memory Allocation type: string MODE: From 944b5fdd3e9a6dd3571c28a7f6e0d892c090272e Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 21:33:29 -0400 Subject: [PATCH 4/6] fix(boot-test): never overwrite non-password settings The payload builder filled anything matching the validator's broad credential pattern that wasn't a non-empty string, so RCON_PORT, ENABLE_RCON and ARK's RCONEnabled/RCONPort became 'boot-123456' in the first runner run. Only fill keys named like passwords (or x-generate), and only when missing or an empty string. Adds a test. Co-Authored-By: Claude Opus 5.5 --- cmd/gbrgames/boottest.go | 14 +++++--- cmd/gbrgames/boottest_test.go | 66 +++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 cmd/gbrgames/boottest_test.go diff --git a/cmd/gbrgames/boottest.go b/cmd/gbrgames/boottest.go index 6477bca..2b6167e 100644 --- a/cmd/gbrgames/boottest.go +++ b/cmd/gbrgames/boottest.go @@ -21,6 +21,10 @@ const bootTestNetwork = "gbr-boottest" var publicPort = regexp.MustCompile(`\{PUBLIC_PORT_(\d+)\}`) var savedValue = regexp.MustCompile(`\{saved:[A-Za-z0-9_.-]+\}`) +// Narrower than the validator's sensitiveKey: that one also matches settings like RCON_PORT, which +// are fine to flag as "don't put secrets here" but must never be overwritten with a password. +var passwordKey = regexp.MustCompile(`(?i)(pass(word)?|secret|token)`) + // runBootTestPayload writes the DEPLOY_SERVER message the panel would send for this game, plus the // boot-test settings the node-agent's BootTest reads. Secrets come from environment variables named // in test.yaml's 'secrets'. @@ -71,10 +75,12 @@ func runBootTestPayload(args []string) error { // would type in, and some games refuse to start without) get a throwaway value too for key, p := range schemaProperties(g) { prop, _ := p.(map[string]any) - if prop["x-generate"] != nil || sensitiveKey.MatchString(key) { - if s, _ := config[key].(string); s == "" { - config[key] = "boot-" + randomSuffix() - } + if prop["x-generate"] == nil && !passwordKey.MatchString(key) { + continue + } + // Only missing or empty text: ports, booleans and other values are never touched + if v, present := config[key]; !present || v == "" { + config[key] = "boot-" + randomSuffix() } } for k, v := range config { diff --git a/cmd/gbrgames/boottest_test.go b/cmd/gbrgames/boottest_test.go new file mode 100644 index 0000000..21e6fb9 --- /dev/null +++ b/cmd/gbrgames/boottest_test.go @@ -0,0 +1,66 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBootTestPayloadOnlyFillsBlankPasswords(t *testing.T) { + dir := t.TempDir() + game := filepath.Join(dir, "games", "demo") + if err := os.MkdirAll(game, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(game, "game.yaml"), []byte(`id: demo +name: Demo +defaultDockerImage: debian:12 +installMethod: NONE +defaultConfig: + RCON_PORT: 25575 + ENABLE_RCON: true + RCON_PASSWORD: "" + ServerPassword: "" + AdminPassword: kept-by-owner + MOTD: "{SERVER_NAME} on {PUBLIC_PORT_25565}" +configSchema: + properties: + RCON_PORT: {type: integer} + ENABLE_RCON: {type: boolean} + RCON_PASSWORD: {type: string, x-generate: {type: password}} + ServerPassword: {type: string} + AdminPassword: {type: string} + MOTD: {type: string} +`), 0o644) + + out := filepath.Join(dir, "payload.json") + if err := runBootTestPayload([]string{"-games", filepath.Join(dir, "games"), "-out", out, "demo"}); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(out) + var payload struct{ GameConfig map[string]any } + if err := json.Unmarshal(raw, &payload); err != nil { + t.Fatal(err) + } + c := payload.GameConfig + + if c["RCON_PORT"] != float64(25575) { + t.Errorf("RCON_PORT changed to %v", c["RCON_PORT"]) + } + if c["ENABLE_RCON"] != true { + t.Errorf("ENABLE_RCON changed to %v", c["ENABLE_RCON"]) + } + for _, k := range []string{"RCON_PASSWORD", "ServerPassword"} { + if s, _ := c[k].(string); !strings.HasPrefix(s, "boot-") { + t.Errorf("%s not filled: %v", k, c[k]) + } + } + if c["AdminPassword"] != "kept-by-owner" { + t.Errorf("AdminPassword overwritten: %v", c["AdminPassword"]) + } + if c["MOTD"] != "gbr-boot-test on 25565" { + t.Errorf("placeholders not resolved: %v", c["MOTD"]) + } +} From 2ad445ffdd088d17f69ea8cf883aa078f8827bb7 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 22:17:03 -0400 Subject: [PATCH 5/6] fix(ark): run as the image's own user; honour custom RCON ports ARK's image is built around user gameserver (uid 25000) and locks /home/gameserver to it, so under the node's default 1000:1000 the startup script got "Permission denied" and the server restart-looped, on real nodes as well as in boot tests. Set containerUser 25000:25000 (needs node-agent and panel support for containerUser). The startup command read ${RCON_PORT}, but the node passes the RCONPort setting as RCONPORT, so custom RCON ports were ignored for 27020. Boot-tested in the Linux agent image: up 30 minutes with no restart. The test now waits 10 minutes, covering the first ~13 GB download. Co-Authored-By: Claude Opus 5.5 --- cmd/gbrgames/boottest.go | 1 + cmd/gbrgames/import.go | 2 +- games/ark-survival-ascended-crossplay/game.yaml | 3 ++- games/ark-survival-ascended-crossplay/test.yaml | 5 ++++- schema/game.schema.json | 5 +++++ 5 files changed, 13 insertions(+), 3 deletions(-) diff --git a/cmd/gbrgames/boottest.go b/cmd/gbrgames/boottest.go index 2b6167e..7ad80b4 100644 --- a/cmd/gbrgames/boottest.go +++ b/cmd/gbrgames/boottest.go @@ -119,6 +119,7 @@ func runBootTestPayload(args []string) error { "configFormatOptions": g["configFormatOptions"], "startupCommand": g["defaultStartupCommand"], "memoryLimitMb": g["requiredMemoryMb"], + "containerUser": g["containerUser"], "dockerNetwork": bootTestNetwork, "logFilePath": g["logFilePath"], "readyPattern": g["readyPattern"], diff --git a/cmd/gbrgames/import.go b/cmd/gbrgames/import.go index 5ad9848..79da1d5 100644 --- a/cmd/gbrgames/import.go +++ b/cmd/gbrgames/import.go @@ -18,7 +18,7 @@ import ( // (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. var gameFields = []string{ "id", "name", - "defaultDockerImage", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", + "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", "installMethod", "installConfig", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "agnosticCommandConfig", "clusterConfig", diff --git a/games/ark-survival-ascended-crossplay/game.yaml b/games/ark-survival-ascended-crossplay/game.yaml index e34b705..06038f9 100644 --- a/games/ark-survival-ascended-crossplay/game.yaml +++ b/games/ark-survival-ascended-crossplay/game.yaml @@ -2,7 +2,8 @@ id: ark-survival-ascended-crossplay name: 'ARK: Survival Ascended (Crossplay Dedicated Server)' defaultDockerImage: mschnitzer/asa-linux-server:latest -defaultStartupCommand: bash -c 'mkdir -p /server /data/Steam /data/steamcmd /data/cluster-shared && rm -rf /home/gameserver/server-files /home/gameserver/Steam /home/gameserver/steamcmd /home/gameserver/cluster-shared && ln -sfn /server /home/gameserver/server-files && ln -sfn /data/Steam /home/gameserver/Steam && ln -sfn /data/steamcmd /home/gameserver/steamcmd && ln -sfn /data/cluster-shared /home/gameserver/cluster-shared && export ASA_START_PARAMS="${ASA_START_PARAMS:-${SERVER_MAP:-TheIsland_WP}?listen?Port=${PORT:-7777}?RCONPort=${RCON_PORT:-27020}?RCONEnabled=True -WinLiveMaxPlayers=${MAX_PLAYERS:-70} -ServerPlatform=ALL}" && exec /usr/bin/start_server' +containerUser: "25000:25000" +defaultStartupCommand: bash -c 'mkdir -p /server /data/Steam /data/steamcmd /data/cluster-shared && rm -rf /home/gameserver/server-files /home/gameserver/Steam /home/gameserver/steamcmd /home/gameserver/cluster-shared && ln -sfn /server /home/gameserver/server-files && ln -sfn /data/Steam /home/gameserver/Steam && ln -sfn /data/steamcmd /home/gameserver/steamcmd && ln -sfn /data/cluster-shared /home/gameserver/cluster-shared && export ASA_START_PARAMS="${ASA_START_PARAMS:-${SERVER_MAP:-TheIsland_WP}?listen?Port=${PORT:-7777}?RCONPort=${RCONPORT:-27020}?RCONEnabled=True -WinLiveMaxPlayers=${MAX_PLAYERS:-70} -ServerPlatform=ALL}" && exec /usr/bin/start_server' requiredMemoryMb: 16384 requiredDiskMb: 32768 defaultPorts: diff --git a/games/ark-survival-ascended-crossplay/test.yaml b/games/ark-survival-ascended-crossplay/test.yaml index 1b49f91..f3ff373 100644 --- a/games/ark-survival-ascended-crossplay/test.yaml +++ b/games/ark-survival-ascended-crossplay/test.yaml @@ -1,3 +1,6 @@ # yaml-language-server: $schema=../../schema/test.schema.json # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. -timeoutSec: 900 +# No readyPattern yet: the server must stay up for 10 minutes, which covers the ~13 GB first +# download and Proton's slow first start. +settleSec: 600 +timeoutSec: 2400 diff --git a/schema/game.schema.json b/schema/game.schema.json index 1287166..7ba4bf9 100644 --- a/schema/game.schema.json +++ b/schema/game.schema.json @@ -16,6 +16,11 @@ "name": { "description": "Display name shown to users.", "type": "string", "minLength": 1 }, "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.", + "type": ["string", "null"], + "pattern": "^[1-9][0-9]{0,8}:[1-9][0-9]{0,8}$" + }, "defaultStartupCommand": { "description": "Command run inside the container. May use config placeholders.", "type": ["string", "null"] }, "requiredMemoryMb": { "type": ["integer", "null"], "minimum": 128 }, "requiredDiskMb": { "type": ["integer", "null"], "minimum": 128 },