Compare commits

...
7 Commits
Author SHA1 Message Date
greg 309c667735 Merge pull request 'ci: publish templates to the control panel on main' (#1) from feat/publish-to-panel into main
Validate game templates / validate (push) Successful in 9m31s
Validate game templates / boot-test (push) Successful in 25m46s
Validate game templates / publish (push) Successful in 9m31s
2026-10-25 05:13:26 +00:00
gregandClaude Opus 5.5 b5b7712c87 ci: re-run push checks with the containerUser-capable agent
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / boot-test (push) Successful in 25m47s
Validate game templates / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 00:11:47 -04:00
gregandClaude Opus 5.5 2ad445ffdd fix(ark): run as the image's own user; honour custom RCON ports
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / boot-test (push) Failing after 11m8s
Validate game templates / publish (push) Skipped
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 <noreply@anthropic.com>
2026-10-24 22:17:03 -04:00
gregandClaude Opus 5.5 944b5fdd3e fix(boot-test): never overwrite non-password settings
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / boot-test (push) Failing after 11m5s
Validate game templates / publish (push) Skipped
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 <noreply@anthropic.com>
2026-10-24 21:33:29 -04:00
gregandClaude Opus 5.5 e8edf853f7 fix(minecraft): 3G heap so the server fits its 4 GB minimum
Validate game templates / validate (push) Successful in 9m33s
Validate game templates / boot-test (push) Failing after 11m34s
Validate game templates / publish (push) Skipped
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 <noreply@anthropic.com>
2026-10-24 20:36:24 -04:00
gregandClaude Opus 5.5 e9ff989246 ci: boot-test changed games before publishing
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / publish (push) Canceled after 0s
Validate game templates / boot-test (push) Canceled after 5m15s
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 <noreply@anthropic.com>
2026-10-24 17:49:19 -04:00
gregandClaude Opus 5.5 5fb7736be2 ci: publish templates to the control panel on main
Validate game templates / validate (push) Successful in 9m31s
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 <noreply@anthropic.com>
2026-10-24 17:12:50 -04:00
24 changed files with 752 additions and 19 deletions

No files matched your search

+93 -3
View File
@@ -1,15 +1,90 @@
name: Validate game templates name: Validate game templates
# Push-only on purpose: this repo is public, and a pull_request trigger would run a fork's # 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. # still checked through their push.
on: on:
push: push:
workflow_dispatch: 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
jobs: jobs:
validate: validate:
runs-on: ubuntu-latest 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: steps:
- name: Checkout Code - name: Checkout Code
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -19,5 +94,20 @@ jobs:
with: with:
go-version-file: go.mod go-version-file: go.mod
- name: Validate # Unlike the node-agent notify, a failure here fails the run, because the panel would
run: go run ./cmd/gbrgames validate # otherwise keep serving the previous templates silently.
- name: Publish to Control Panel
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
+55
View File
@@ -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 <repo>/scripts/runner/docker-compose.yml <repo>/scripts/runner/config.yaml .
echo "GITEA_RUNNER_REGISTRATION_TOKEN=<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.
+169
View File
@@ -0,0 +1,169 @@
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_.-]+\}`)
// 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'.
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 && !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 {
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"],
"containerUser": g["containerUser"],
"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())
}
+66
View File
@@ -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"])
}
}
+58
View File
@@ -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)
}
+47 -5
View File
@@ -8,6 +8,7 @@ import (
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
@@ -17,11 +18,11 @@ import (
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out. // (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
var gameFields = []string{ var gameFields = []string{
"id", "name", "id", "name",
"defaultDockerImage", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts", "defaultDockerImage", "containerUser", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
"installMethod", "installConfig", "installMethod", "installConfig",
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions", "defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
"agnosticCommandConfig", "clusterConfig", "agnosticCommandConfig", "clusterConfig",
"logFilePath", "backupPaths", "maxBackupSizeMb", "logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey", "publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
"aiCoverPrompt", "aiCoverPrompt",
} }
@@ -82,11 +83,11 @@ func writeGame(path string, g map[string]any) error {
if !ok || isEmpty(v) { if !ok || isEmpty(v) {
continue continue
} }
var val yaml.Node val, err := toNode(normalize(v))
if err := val.Encode(normalize(v)); err != nil { if err != nil {
return err 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) 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) 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. // normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
func normalize(v any) any { func normalize(v any) any {
switch x := v.(type) { switch x := v.(type) {
+48
View File
@@ -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)
}
}
+7 -1
View File
@@ -2,6 +2,8 @@
// //
// go run ./cmd/gbrgames validate # check every games/<id>/game.yaml (+ test.yaml) // go run ./cmd/gbrgames validate # check every games/<id>/game.yaml (+ test.yaml)
// go run ./cmd/gbrgames import export.json # write games/<id>/ from GET /api/v1/admin/games // go run ./cmd/gbrgames import export.json # write games/<id>/ 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 package main
import ( import (
@@ -19,6 +21,10 @@ func main() {
err = runValidate(os.Args[2:]) err = runValidate(os.Args[2:])
case "import": case "import":
err = runImport(os.Args[2:]) err = runImport(os.Args[2:])
case "export":
err = runExport(os.Args[2:])
case "boottest-payload":
err = runBootTestPayload(os.Args[2:])
default: default:
usage() usage()
} }
@@ -29,6 +35,6 @@ func main() {
} }
func usage() { 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] | boottest-payload [-out FILE] ID")
os.Exit(2) os.Exit(2)
} }
+19
View File
@@ -94,6 +94,17 @@ func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema)
} }
scanPrivateIPs(raw, "game.yaml", add) 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) defaults, _ := g["defaultConfig"].(map[string]any)
props := schemaProperties(g) props := schemaProperties(g)
known := map[string]bool{} 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) 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 return problems
} }
@@ -2,7 +2,8 @@
id: ark-survival-ascended-crossplay id: ark-survival-ascended-crossplay
name: 'ARK: Survival Ascended (Crossplay Dedicated Server)' name: 'ARK: Survival Ascended (Crossplay Dedicated Server)'
defaultDockerImage: mschnitzer/asa-linux-server:latest 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 requiredMemoryMb: 16384
requiredDiskMb: 32768 requiredDiskMb: 32768
defaultPorts: defaultPorts:
@@ -282,7 +283,7 @@ configFormatOptions:
footer: null footer: null
header: '[ServerSettings]' header: '[ServerSettings]'
quoteStrings: false quoteStrings: false
separator: "" separator: "\n"
trueValue: "True" trueValue: "True"
unquotedKeys: [] unquotedKeys: []
uppercaseKeys: false uppercaseKeys: false
@@ -1,3 +1,6 @@
# yaml-language-server: $schema=../../schema/test.schema.json # 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'. # 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
+7 -4
View File
@@ -31,7 +31,7 @@ defaultConfig:
HARDCORE: false HARDCORE: false
JVM_OPTS: -DgeyserTransport=nethernet -DgeyserAdvertiseAddresses={PUBLIC_IP}:{PUBLIC_PORT_19132} JVM_OPTS: -DgeyserTransport=nethernet -DgeyserAdvertiseAddresses={PUBLIC_IP}:{PUBLIC_PORT_19132}
MAX_PLAYERS: 20 MAX_PLAYERS: 20
MEMORY: 4G MEMORY: 3G
MODE: survival MODE: survival
MOTD: GBRHosting - {SERVER_NAME} MOTD: GBRHosting - {SERVER_NAME}
ONLINE_MODE: true ONLINE_MODE: true
@@ -117,8 +117,8 @@ configSchema:
type: integer type: integer
MEMORY: MEMORY:
category: System & General category: System & General
default: 4G default: 3G
description: Maximum heap memory allocated to the Java process (e.g. 4G, 6G, 8G). 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 title: Memory Allocation
type: string type: string
MODE: MODE:
@@ -239,7 +239,7 @@ configFormatOptions:
footer: "" footer: ""
header: "" header: ""
quoteStrings: false quoteStrings: false
separator: "" separator: "\n"
trueValue: "true" trueValue: "true"
unquotedKeys: [] unquotedKeys: []
uppercaseKeys: false uppercaseKeys: false
@@ -376,6 +376,9 @@ agnosticCommandConfig:
restPasswordKey: null restPasswordKey: null
restPortKey: null restPortKey: null
restUser: null restUser: null
readyPattern: 'Done \([0-9.]+s\)! For help'
failPatterns:
- You need to agree to the EULA
backupPaths: backupPaths:
- world - world
- world_nether - world_nether
+1
View File
@@ -2,4 +2,5 @@
# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'. # Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.
config: config:
EULA: true EULA: true
readyExample: '[21:37:42 INFO]: Done (16.390s)! For help, type "help"'
timeoutSec: 900 timeoutSec: 900
@@ -88,11 +88,11 @@ configFileFormat: STANDARD
configFormatOptions: configFormatOptions:
assignment: = assignment: =
falseValue: "False" falseValue: "False"
footer: "" footer: "\n"
header: | header: |
[/Script/Dominion.DedicatedServerSettings] [/Script/Dominion.DedicatedServerSettings]
quoteStrings: false quoteStrings: false
separator: "" separator: "\n"
trueValue: "True" trueValue: "True"
uppercaseKeys: false uppercaseKeys: false
agnosticCommandConfig: agnosticCommandConfig:
@@ -1,3 +1,7 @@
# yaml-language-server: $schema=../../schema/test.schema.json # 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'. # 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 timeoutSec: 2400
+1 -1
View File
@@ -155,7 +155,7 @@ configFormatOptions:
footer: "" footer: ""
header: "" header: ""
quoteStrings: false quoteStrings: false
separator: "" separator: "\n"
trueValue: "true" trueValue: "true"
unquotedKeys: [] unquotedKeys: []
uppercaseKeys: false uppercaseKeys: false
+14
View File
@@ -16,6 +16,11 @@
"name": { "description": "Display name shown to users.", "type": "string", "minLength": 1 }, "name": { "description": "Display name shown to users.", "type": "string", "minLength": 1 },
"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": {
"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"] }, "defaultStartupCommand": { "description": "Command run inside the container. May use config placeholders.", "type": ["string", "null"] },
"requiredMemoryMb": { "type": ["integer", "null"], "minimum": 128 }, "requiredMemoryMb": { "type": ["integer", "null"], "minimum": 128 },
"requiredDiskMb": { "type": ["integer", "null"], "minimum": 128 }, "requiredDiskMb": { "type": ["integer", "null"], "minimum": 128 },
@@ -51,6 +56,15 @@
"clusterConfig": { "type": ["object", "null"] }, "clusterConfig": { "type": ["object", "null"] },
"logFilePath": { "type": ["string", "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" } }, "backupPaths": { "type": ["array", "null"], "items": { "type": "string" } },
"maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 }, "maxBackupSizeMb": { "type": ["integer", "null"], "minimum": 1 },
+10
View File
@@ -22,6 +22,16 @@
"type": "integer", "type": "integer",
"minimum": 60, "minimum": 60,
"maximum": 7200 "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
} }
} }
} }
+50
View File
@@ -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 ]
+24
View File
@@ -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
+10
View File
@@ -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
+17
View File
@@ -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
@@ -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
+30
View File
@@ -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"