Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309c667735 | ||
|
|
b5b7712c87 | ||
|
|
2ad445ffdd | ||
|
|
944b5fdd3e | ||
|
|
e8edf853f7 | ||
|
|
e9ff989246 | ||
|
|
5fb7736be2 |
No files matched your search
@@ -1,15 +1,90 @@
|
||||
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
|
||||
|
||||
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
|
||||
@@ -19,5 +94,20 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Validate
|
||||
run: go run ./cmd/gbrgames validate
|
||||
# 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
|
||||
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
|
||||
@@ -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.
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -17,11 +18,11 @@ 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",
|
||||
"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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
//
|
||||
// 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 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 (
|
||||
@@ -19,6 +21,10 @@ func main() {
|
||||
err = runValidate(os.Args[2:])
|
||||
case "import":
|
||||
err = runImport(os.Args[2:])
|
||||
case "export":
|
||||
err = runExport(os.Args[2:])
|
||||
case "boottest-payload":
|
||||
err = runBootTestPayload(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
@@ -29,6 +35,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] | boottest-payload [-out FILE] ID")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -282,7 +283,7 @@ configFormatOptions:
|
||||
footer: null
|
||||
header: '[ServerSettings]'
|
||||
quoteStrings: false
|
||||
separator: ""
|
||||
separator: "\n"
|
||||
trueValue: "True"
|
||||
unquotedKeys: []
|
||||
uppercaseKeys: false
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -155,7 +155,7 @@ configFormatOptions:
|
||||
footer: ""
|
||||
header: ""
|
||||
quoteStrings: false
|
||||
separator: ""
|
||||
separator: "\n"
|
||||
trueValue: "true"
|
||||
unquotedKeys: []
|
||||
uppercaseKeys: false
|
||||
|
||||
@@ -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 },
|
||||
@@ -51,6 +56,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 },
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+50
@@ -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 ]
|
||||
Executable
+24
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+30
@@ -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"
|
||||
Reference in new issue
Block a user