Files
gbr-games/cmd/gbrgames/boottest.go
T
gregandClaude Opus 5.5 944b5fdd3e
Validate game templates / validate (push) Successful in 9m32s
Validate game templates / boot-test (push) Failing after 11m5s
Validate game templates / publish (push) Skipped
fix(boot-test): never overwrite non-password settings
The payload builder filled anything matching the validator's broad
credential pattern that wasn't a non-empty string, so RCON_PORT,
ENABLE_RCON and ARK's RCONEnabled/RCONPort became 'boot-123456' in the
first runner run. Only fill keys named like passwords (or x-generate),
and only when missing or an empty string. Adds a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 21:33:29 -04:00

169 lines
5.0 KiB
Go

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"],
"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())
}