Validate game templates / validate (push) Successful in 10m6s
Public game templates for GBR Hosting, one games/<id>/game.yaml per game, imported from the panel. A Go tool validates them against JSON Schemas and checks for credentials, private addresses and consents that default to accepted; CI runs it on every push. Admin/RCON passwords are no longer shared defaults: they are blank with x-generate so the panel creates one per server, and Palworld/ARK no longer publish their RCON/REST ports. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
163 lines
4.2 KiB
Go
163 lines
4.2 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// Game fields in the order they are written to game.yaml. Panel-owned business settings
|
|
// (enabled, allowPaidHosting, allowPrivateHosting, imageUrl) are deliberately left out.
|
|
var gameFields = []string{
|
|
"id", "name",
|
|
"defaultDockerImage", "defaultStartupCommand", "requiredMemoryMb", "requiredDiskMb", "defaultPorts",
|
|
"installMethod", "installConfig",
|
|
"defaultConfig", "configSchema", "configFilePath", "configFileFormat", "configFormatOptions",
|
|
"agnosticCommandConfig", "clusterConfig",
|
|
"logFilePath", "backupPaths", "maxBackupSizeMb",
|
|
"publicIpConfigKey", "maxPlayersConfigKey", "mapConfigKey",
|
|
"aiCoverPrompt",
|
|
}
|
|
|
|
const gameHeader = "# yaml-language-server: $schema=../../schema/game.schema.json\n"
|
|
const testHeader = "# yaml-language-server: $schema=../../schema/test.schema.json\n" +
|
|
"# Values CI uses to boot-test this game. Never put real credentials here: map them under 'secrets'.\n"
|
|
|
|
func runImport(args []string) error {
|
|
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
|
gamesDir := fs.String("games", "games", "directory to write one folder per game into")
|
|
all := fs.Bool("all", false, "also import games that are disabled in the panel")
|
|
fs.Parse(args)
|
|
if fs.NArg() != 1 {
|
|
return errors.New("import needs the path to a GET /api/v1/admin/games export")
|
|
}
|
|
|
|
raw, err := os.ReadFile(fs.Arg(0))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var games []map[string]any
|
|
if err := json.Unmarshal(raw, &games); err != nil {
|
|
return fmt.Errorf("export is not a JSON array of games: %w", err)
|
|
}
|
|
|
|
for _, g := range games {
|
|
id, _ := g["id"].(string)
|
|
if id == "" {
|
|
return errors.New("export contains a game without an id")
|
|
}
|
|
if g["enabled"] == false && !*all {
|
|
fmt.Printf("skip %s (disabled in panel; use -all to include)\n", id)
|
|
continue
|
|
}
|
|
dir := filepath.Join(*gamesDir, id)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := writeGame(filepath.Join(dir, "game.yaml"), g); err != nil {
|
|
return fmt.Errorf("%s: %w", id, err)
|
|
}
|
|
testPath := filepath.Join(dir, "test.yaml")
|
|
if _, err := os.Stat(testPath); errors.Is(err, os.ErrNotExist) {
|
|
if err := writeTest(testPath, g); err != nil {
|
|
return fmt.Errorf("%s: %w", id, err)
|
|
}
|
|
}
|
|
fmt.Printf("wrote %s\n", dir)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func writeGame(path string, g map[string]any) error {
|
|
doc := &yaml.Node{Kind: yaml.MappingNode}
|
|
for _, f := range gameFields {
|
|
v, ok := g[f]
|
|
if !ok || isEmpty(v) {
|
|
continue
|
|
}
|
|
var val yaml.Node
|
|
if err := val.Encode(normalize(v)); err != nil {
|
|
return err
|
|
}
|
|
doc.Content = append(doc.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: f}, &val)
|
|
}
|
|
return writeYAML(path, gameHeader, doc)
|
|
}
|
|
|
|
// writeTest seeds a boot-test fixture: consents (EULAs) accepted, a timeout sized to the install method.
|
|
func writeTest(path string, g map[string]any) error {
|
|
cfg := map[string]any{}
|
|
for key, p := range schemaProperties(g) {
|
|
if prop, ok := p.(map[string]any); ok {
|
|
if c, ok := prop["x-consent"]; ok && c != false {
|
|
cfg[key] = true
|
|
}
|
|
}
|
|
}
|
|
timeout := 900
|
|
if g["installMethod"] == "STEAMCMD" {
|
|
timeout = 2400
|
|
}
|
|
test := map[string]any{"timeoutSec": timeout}
|
|
if len(cfg) > 0 {
|
|
test["config"] = cfg
|
|
}
|
|
var doc yaml.Node
|
|
if err := doc.Encode(test); err != nil {
|
|
return err
|
|
}
|
|
return writeYAML(path, testHeader, &doc)
|
|
}
|
|
|
|
func writeYAML(path, header string, doc *yaml.Node) error {
|
|
var b strings.Builder
|
|
b.WriteString(header)
|
|
enc := yaml.NewEncoder(&b)
|
|
enc.SetIndent(2)
|
|
if err := enc.Encode(doc); err != nil {
|
|
return err
|
|
}
|
|
enc.Close()
|
|
return os.WriteFile(path, []byte(b.String()), 0o644)
|
|
}
|
|
|
|
// 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) {
|
|
case float64:
|
|
if x == math.Trunc(x) && math.Abs(x) < 1<<53 {
|
|
return int64(x)
|
|
}
|
|
case map[string]any:
|
|
for k, e := range x {
|
|
x[k] = normalize(e)
|
|
}
|
|
case []any:
|
|
for i, e := range x {
|
|
x[i] = normalize(e)
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func isEmpty(v any) bool {
|
|
switch x := v.(type) {
|
|
case nil:
|
|
return true
|
|
case string:
|
|
return x == ""
|
|
case map[string]any:
|
|
return len(x) == 0
|
|
case []any:
|
|
return len(x) == 0
|
|
}
|
|
return false
|
|
}
|