From e9ff989246cebd0324832c5f8823124c370dc038 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 24 Oct 2026 17:49:19 -0400 Subject: [PATCH] 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"