The first real run of the port check found that the crossplay Minecraft server opens no UDP socket at startup: Bedrock goes through Geyser's NetherNet transport, and 19132/udp is not open after boot. It is listed as unboundPorts in that game's test.yaml, with the reason, until a Bedrock client confirms how players reach it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
212 lines
6.9 KiB
Go
212 lines
6.9 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)")
|
|
fillBlank := fs.Bool("fill-blank-passwords", false, "also give blank password settings a throwaway value. Never for a "+
|
|
"boot test: a real server doesn't get one. Only the AI builder uses it, to find out whether an empty setting is "+
|
|
"why a draft fails")
|
|
keep := fs.Bool("keep", false, "leave the container and its volume after the test, for inspection")
|
|
fs.Parse(args)
|
|
if fs.NArg() != 1 {
|
|
return errors.New("boottest-payload needs a game id")
|
|
}
|
|
payload, err := bootTestPayload(*gamesDir, fs.Arg(0), *fillBlank)
|
|
var missing *missingSecretError
|
|
if errors.As(err, &missing) {
|
|
fmt.Fprintln(os.Stderr, "skip: "+missing.Error())
|
|
os.Exit(exitMissingSecret)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if *keep {
|
|
payload["keepContainer"] = true
|
|
}
|
|
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)
|
|
}
|
|
|
|
type missingSecretError struct{ game, secret, key string }
|
|
|
|
func (e *missingSecretError) Error() string {
|
|
return fmt.Sprintf("%s needs secret %s for %s", e.game, e.secret, e.key)
|
|
}
|
|
|
|
// bootTestPayload builds the DEPLOY_SERVER message the panel would send for a game, plus the boot-test
|
|
// settings BootTest reads: ready and fail patterns, timeouts, how long the server must stay up after it is
|
|
// ready, and the container ports it must then listen on.
|
|
func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error) {
|
|
dir := filepath.Join(gamesDir, id)
|
|
g, err := loadMap(filepath.Join(dir, "game.yaml"))
|
|
if err != nil {
|
|
return nil, 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 nil, 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 == "" {
|
|
return nil, &missingSecretError{game: id, secret: fmt.Sprint(name), key: key}
|
|
}
|
|
config[key] = value
|
|
}
|
|
}
|
|
// Exactly what a new server gets: the panel fills blank x-generate fields on deploy and nothing else, so
|
|
// the boot test does too. Filling other blank passwords hid templates whose server won't start without
|
|
// one: they passed CI and failed on the first real deploy.
|
|
for key, p := range schemaProperties(g) {
|
|
prop, _ := p.(map[string]any)
|
|
if prop["x-generate"] == nil && (!fillBlank || !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, hold := 900, 120, 60
|
|
if g["installMethod"] == "STEAMCMD" {
|
|
timeout = 2400
|
|
}
|
|
for key, dst := range map[string]*int{"timeoutSec": &timeout, "settleSec": &settle, "holdSec": &hold} {
|
|
if v, ok := test[key].(json.Number); ok {
|
|
if n, err := v.Int64(); err == nil {
|
|
*dst = int(n)
|
|
}
|
|
}
|
|
}
|
|
// Once up, the server must listen on what nodes publish: the container side of every default port
|
|
// test.yaml's unboundPorts are published but only opened once a player connects; each is listed by
|
|
// hand, with the reason in a comment
|
|
unbound := map[string]bool{}
|
|
if list, ok := test["unboundPorts"].([]any); ok {
|
|
for _, p := range list {
|
|
unbound[fmt.Sprint(p)] = true
|
|
}
|
|
}
|
|
expectPorts := []any{}
|
|
if ports, ok := g["defaultPorts"].([]any); ok {
|
|
for _, p := range ports {
|
|
if !unbound[fmt.Sprint(p)] {
|
|
expectPorts = append(expectPorts, fmt.Sprint(p))
|
|
}
|
|
}
|
|
}
|
|
|
|
return 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,
|
|
"holdSec": hold,
|
|
"expectPorts": expectPorts,
|
|
}, nil
|
|
}
|
|
|
|
// 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())
|
|
}
|