ci: boot-test changed games before publishing
Only games whose folder changed are boot-tested (against the previous
main commit, or against main on branches; manual runs pick games). Each
one is deployed by the node-agent's real deploy code on a dedicated
game-test runner, in a network that can reach the internet but not the
LAN. Publishing to the panel runs only if they all booted.
- boottest-payload builds the DEPLOY_SERVER message the panel would send
- readyPattern/failPatterns in game.yaml; readyExample in test.yaml is
checked against the pattern during validation
- secrets in test.yaml come from CI; a game missing one is skipped
- Minecraft gets a ready pattern; Dragonwilds needs DRAGONWILDS_OWNER_ID
- RUNNER.md + scripts/runner: runner, isolated network, systemd unit
Also fixes newline values ("\n" separators/footers) that yaml.v3 wrote
as "" on import, which would have broken ARK and Dragonwilds config
files once synced, and adds a round-trip test for the importer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5fb7736be2
commit
e9ff989246
21 files changed
+585
-18
No files matched your search
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Exit code for "this game needs a secret CI doesn't have": the workflow skips it instead of failing.
|
||||
const exitMissingSecret = 3
|
||||
|
||||
const bootTestNetwork = "gbr-boottest"
|
||||
|
||||
var publicPort = regexp.MustCompile(`\{PUBLIC_PORT_(\d+)\}`)
|
||||
var savedValue = regexp.MustCompile(`\{saved:[A-Za-z0-9_.-]+\}`)
|
||||
|
||||
// runBootTestPayload writes the DEPLOY_SERVER message the panel would send for this game, plus the
|
||||
// boot-test settings the node-agent's BootTest reads. Secrets come from environment variables named
|
||||
// in test.yaml's 'secrets'.
|
||||
func runBootTestPayload(args []string) error {
|
||||
fs := flag.NewFlagSet("boottest-payload", flag.ExitOnError)
|
||||
gamesDir := fs.String("games", "games", "directory holding one folder per game")
|
||||
out := fs.String("out", "", "file to write (default stdout)")
|
||||
fs.Parse(args)
|
||||
if fs.NArg() != 1 {
|
||||
return errors.New("boottest-payload needs a game id")
|
||||
}
|
||||
id := fs.Arg(0)
|
||||
dir := filepath.Join(*gamesDir, id)
|
||||
|
||||
g, err := loadMap(filepath.Join(dir, "game.yaml"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
test := map[string]any{}
|
||||
if _, err := os.Stat(filepath.Join(dir, "test.yaml")); err == nil {
|
||||
if test, err = loadMap(filepath.Join(dir, "test.yaml")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
config := map[string]any{}
|
||||
if d, ok := g["defaultConfig"].(map[string]any); ok {
|
||||
for k, v := range d {
|
||||
config[k] = v
|
||||
}
|
||||
}
|
||||
if c, ok := test["config"].(map[string]any); ok {
|
||||
for k, v := range c {
|
||||
config[k] = v
|
||||
}
|
||||
}
|
||||
if s, ok := test["secrets"].(map[string]any); ok {
|
||||
for key, name := range s {
|
||||
value, ok := os.LookupEnv(fmt.Sprint(name))
|
||||
if !ok || value == "" {
|
||||
fmt.Fprintf(os.Stderr, "skip: %s needs secret %s for %s\n", id, name, key)
|
||||
os.Exit(exitMissingSecret)
|
||||
}
|
||||
config[key] = value
|
||||
}
|
||||
}
|
||||
// Like the panel fills blank x-generate fields on deploy; other blank passwords (which an owner
|
||||
// would type in, and some games refuse to start without) get a throwaway value too
|
||||
for key, p := range schemaProperties(g) {
|
||||
prop, _ := p.(map[string]any)
|
||||
if prop["x-generate"] != nil || sensitiveKey.MatchString(key) {
|
||||
if s, _ := config[key].(string); s == "" {
|
||||
config[key] = "boot-" + randomSuffix()
|
||||
}
|
||||
}
|
||||
}
|
||||
for k, v := range config {
|
||||
if s, ok := v.(string); ok {
|
||||
config[k] = resolvePlaceholders(s)
|
||||
}
|
||||
}
|
||||
|
||||
timeout, settle := 900, 120
|
||||
if g["installMethod"] == "STEAMCMD" {
|
||||
timeout = 2400
|
||||
}
|
||||
if v, ok := test["timeoutSec"].(json.Number); ok {
|
||||
if n, err := v.Int64(); err == nil {
|
||||
timeout = int(n)
|
||||
}
|
||||
}
|
||||
if v, ok := test["settleSec"].(json.Number); ok {
|
||||
if n, err := v.Int64(); err == nil {
|
||||
settle = int(n)
|
||||
}
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"command": "DEPLOY_SERVER",
|
||||
"serverId": "boottest-" + id,
|
||||
"containerName": "boottest-" + id,
|
||||
"dockerImage": g["defaultDockerImage"],
|
||||
"gameConfig": config,
|
||||
// Readiness is read from logs, so nothing is published on the runner
|
||||
"ports": []any{},
|
||||
"installMethod": g["installMethod"],
|
||||
"installConfig": g["installConfig"],
|
||||
"configFilePath": g["configFilePath"],
|
||||
"configFileFormat": g["configFileFormat"],
|
||||
"configFormatOptions": g["configFormatOptions"],
|
||||
"startupCommand": g["defaultStartupCommand"],
|
||||
"memoryLimitMb": g["requiredMemoryMb"],
|
||||
"dockerNetwork": bootTestNetwork,
|
||||
"logFilePath": g["logFilePath"],
|
||||
"readyPattern": g["readyPattern"],
|
||||
"failPatterns": g["failPatterns"],
|
||||
"timeoutSec": timeout,
|
||||
"settleSec": settle,
|
||||
}
|
||||
b, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *out == "" {
|
||||
_, err = os.Stdout.Write(b)
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(*out, b, 0o644)
|
||||
}
|
||||
|
||||
// resolvePlaceholders stands in for what the panel fills per server; saved values have no owner here.
|
||||
func resolvePlaceholders(s string) string {
|
||||
s = strings.NewReplacer("{SERVER_NAME}", "gbr-boot-test", "{PUBLIC_IP}", "127.0.0.1", "{PUBLIC_HOST}", "127.0.0.1").Replace(s)
|
||||
s = publicPort.ReplaceAllString(s, "$1")
|
||||
s = savedValue.ReplaceAllString(s, "")
|
||||
// The panel strips line breaks from config values before they reach a node
|
||||
return strings.NewReplacer("\r", "", "\n", "").Replace(s)
|
||||
}
|
||||
|
||||
func loadMap(path string) (map[string]any, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, err := loadYAML(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: not a mapping", path)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func randomSuffix() string {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(1_000_000))
|
||||
return fmt.Sprintf("%06d", n.Int64())
|
||||
}
|
||||
+46
-4
@@ -8,6 +8,7 @@ import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -21,7 +22,7 @@ var gameFields = []string{
|
||||
"installMethod", "installConfig",
|
||||
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
|
||||
"agnosticCommandConfig", "clusterConfig",
|
||||
"logFilePath", "backupPaths", "maxBackupSizeMb",
|
||||
"logFilePath", "readyPattern", "failPatterns", "backupPaths", "maxBackupSizeMb",
|
||||
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
|
||||
"aiCoverPrompt",
|
||||
}
|
||||
@@ -82,11 +83,11 @@ func writeGame(path string, g map[string]any) error {
|
||||
if !ok || isEmpty(v) {
|
||||
continue
|
||||
}
|
||||
var val yaml.Node
|
||||
if err := val.Encode(normalize(v)); err != nil {
|
||||
val, err := toNode(normalize(v))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, &val)
|
||||
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, val)
|
||||
}
|
||||
return writeYAML(path, gameHeader, doc)
|
||||
}
|
||||
@@ -128,6 +129,47 @@ func writeYAML(path, header string, doc *yaml.Node) error {
|
||||
return os.WriteFile(path, []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
// toNode builds the YAML node tree by hand because yaml.v3's Node.Encode turns a string made only of
|
||||
// whitespace, like a "\n" config separator, into "" and the value is silently lost.
|
||||
func toNode(v any) (*yaml.Node, error) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
n := &yaml.Node{Kind: yaml.MappingNode}
|
||||
keys := make([]string, 0, len(x))
|
||||
for k := range x {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
val, err := toNode(x[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Content = append(n.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, val)
|
||||
}
|
||||
return n, nil
|
||||
case []any:
|
||||
n := &yaml.Node{Kind: yaml.SequenceNode}
|
||||
for _, e := range x {
|
||||
val, err := toNode(e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.Content = append(n.Content, val)
|
||||
}
|
||||
return n, nil
|
||||
case string:
|
||||
if x != "" && strings.TrimSpace(x) == "" {
|
||||
return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: x, Style: yaml.DoubleQuotedStyle}, nil
|
||||
}
|
||||
}
|
||||
n := &yaml.Node{}
|
||||
if err := n.Encode(v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// normalize turns whole-number floats from encoding/json back into ints so YAML shows 25565, not 25565.0.
|
||||
func normalize(v any) any {
|
||||
switch x := v.(type) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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 (
|
||||
@@ -22,6 +23,8 @@ func main() {
|
||||
err = runImport(os.Args[2:])
|
||||
case "export":
|
||||
err = runExport(os.Args[2:])
|
||||
case "boottest-payload":
|
||||
err = runBootTestPayload(os.Args[2:])
|
||||
default:
|
||||
usage()
|
||||
}
|
||||
@@ -32,6 +35,6 @@ func main() {
|
||||
}
|
||||
|
||||
func usage() {
|
||||
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE]")
|
||||
fmt.Fprintln(os.Stderr, "usage: gbrgames validate [-games DIR] | import [-games DIR] [-all] EXPORT.json | export [-repo R] [-commit SHA] [-out FILE] | boottest-payload [-out FILE] ID")
|
||||
os.Exit(2)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user