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()) }