The builder's boot test filled every blank password-like setting with a throwaway value. A real deploy only fills x-generate fields, so a draft whose server refuses to run with an empty join password passed the builder and CI, and then shut down on its first real deploy. The builder now boots drafts with boottest-payload -as-deployed: only x-generate fields are filled, everything else exactly as the template's defaults. A server that won't run with its own defaults fails the build, and the model is told that empty settings stay empty, so the value must come from a default or x-generate. CI's boot test is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
173 lines
5.3 KiB
Go
173 lines
5.3 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)")
|
|
asDeployed := fs.Bool("as-deployed", false, "settings exactly as a new server gets them: only x-generate fields filled, "+
|
|
"no throwaway passwords (the AI builder boots drafts this way)")
|
|
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, except
|
|
// -as-deployed: a draft must start with exactly what a new server gets
|
|
for key, p := range schemaProperties(g) {
|
|
prop, _ := p.(map[string]any)
|
|
if prop["x-generate"] == nil && (*asDeployed || !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())
|
|
}
|