Compare commits
7
Commits
822b44e2e5
...
309c667735
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
309c667735 | ||
|
|
b5b7712c87 | ||
|
|
2ad445ffdd | ||
|
|
944b5fdd3e | ||
|
|
e8edf853f7 | ||
|
|
e9ff989246 | ||
|
|
5fb7736be2 |
No files matched your search
@@ -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
|
||||||
@@ -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"
|
"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) {
|
||||||
|
|||||||
@@ -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 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)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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