fix(boot-test): run every boot test with exactly what a new server gets
Validate game templates / validate (push) Successful in 22s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

CI's boot test gave every blank password-like setting a throwaway value. A
real deploy only fills x-generate fields, so a template whose server won't
start without one passed CI and failed for the first owner who deployed it.

boottest-payload now fills only x-generate fields, for CI and the builder
alike. The old behaviour is kept as -fill-blank-passwords, used only by
the builder's second boot to tell whether an empty setting is why a draft
fails. Messages and AUTHORING.md no longer say boot tests fill passwords;
a server that needs a join password gets x-generate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 22:01:16 -04:00
1 parent 98ba043bec
commit fe790ad223
6 files changed
+35 -32

No files matched your search

+2
View File
@@ -34,6 +34,8 @@ Know this before writing anything:
**Never put a placeholder or default password in a template.** Shared defaults are readable by anyone **Never put a placeholder or default password in a template.** Shared defaults are readable by anyone
from the public game list. from the public game list.
- Join passwords stay `""`, with `"x-savedValue": {"key": "GAME_JOIN_PASSWORD", "label": "Game join password", "secret": true}`. - Join passwords stay `""`, with `"x-savedValue": {"key": "GAME_JOIN_PASSWORD", "label": "Game join password", "secret": true}`.
If the server can't start without one, give it `"x-generate": {"type": "password"}` as well, so every new server
gets its own. Boot tests run with exactly what a new server gets: empty settings stay empty.
- No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a - No real credentials, tokens or IDs anywhere in this repo. A value a server can't start without (a
player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name. player ID, a license token) goes in `test.yaml` under `secrets`, mapped to a CI secret name.
+7 -6
View File
@@ -32,8 +32,9 @@ func runBootTestPayload(args []string) error {
fs := flag.NewFlagSet("boottest-payload", flag.ExitOnError) fs := flag.NewFlagSet("boottest-payload", flag.ExitOnError)
gamesDir := fs.String("games", "games", "directory holding one folder per game") gamesDir := fs.String("games", "games", "directory holding one folder per game")
out := fs.String("out", "", "file to write (default stdout)") 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, "+ fillBlank := fs.Bool("fill-blank-passwords", false, "also give blank password settings a throwaway value. Never for a "+
"no throwaway passwords (the AI builder boots drafts this way)") "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")
fs.Parse(args) fs.Parse(args)
if fs.NArg() != 1 { if fs.NArg() != 1 {
return errors.New("boottest-payload needs a game id") return errors.New("boottest-payload needs a game id")
@@ -73,12 +74,12 @@ func runBootTestPayload(args []string) error {
config[key] = value config[key] = value
} }
} }
// Like the panel fills blank x-generate fields on deploy; other blank passwords (which an owner // Exactly what a new server gets: the panel fills blank x-generate fields on deploy and nothing else, so
// would type in, and some games refuse to start without) get a throwaway value too, except // the boot test does too. Filling other blank passwords hid templates whose server won't start without
// -as-deployed: a draft must start with exactly what a new server gets // one: they passed CI and failed on the first real deploy.
for key, p := range schemaProperties(g) { for key, p := range schemaProperties(g) {
prop, _ := p.(map[string]any) prop, _ := p.(map[string]any)
if prop["x-generate"] == nil && (*asDeployed || !passwordKey.MatchString(key)) { if prop["x-generate"] == nil && (!*fillBlank || !passwordKey.MatchString(key)) {
continue continue
} }
// Only missing or empty text: ports, booleans and other values are never touched // Only missing or empty text: ports, booleans and other values are never touched
+11 -12
View File
@@ -8,7 +8,7 @@ import (
"testing" "testing"
) )
func TestBootTestPayloadOnlyFillsBlankPasswords(t *testing.T) { func TestBootTestPayloadIsWhatANewServerGets(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
game := filepath.Join(dir, "games", "demo") game := filepath.Join(dir, "games", "demo")
if err := os.MkdirAll(game, 0o755); err != nil { if err := os.MkdirAll(game, 0o755); err != nil {
@@ -52,10 +52,12 @@ configSchema:
if c["ENABLE_RCON"] != true { if c["ENABLE_RCON"] != true {
t.Errorf("ENABLE_RCON changed to %v", c["ENABLE_RCON"]) t.Errorf("ENABLE_RCON changed to %v", c["ENABLE_RCON"])
} }
for _, k := range []string{"RCON_PASSWORD", "ServerPassword"} { // Exactly what a new server gets: the x-generate password is made, a blank join password stays blank
if s, _ := c[k].(string); !strings.HasPrefix(s, "boot-") { if s, _ := c["RCON_PASSWORD"].(string); !strings.HasPrefix(s, "boot-") {
t.Errorf("%s not filled: %v", k, c[k]) t.Errorf("x-generate RCON_PASSWORD not filled: %v", c["RCON_PASSWORD"])
} }
if c["ServerPassword"] != "" {
t.Errorf("ServerPassword must stay empty, as for a new server: %v", c["ServerPassword"])
} }
if c["AdminPassword"] != "kept-by-owner" { if c["AdminPassword"] != "kept-by-owner" {
t.Errorf("AdminPassword overwritten: %v", c["AdminPassword"]) t.Errorf("AdminPassword overwritten: %v", c["AdminPassword"])
@@ -64,8 +66,8 @@ configSchema:
t.Errorf("placeholders not resolved: %v", c["MOTD"]) t.Errorf("placeholders not resolved: %v", c["MOTD"])
} }
// As a new server gets it (what the AI builder boots): only x-generate is filled, a blank join password stays blank // Only for the builder's diagnosis: blank passwords filled too
if err := runBootTestPayload([]string{"-as-deployed", "-games", filepath.Join(dir, "games"), "-out", out, "demo"}); err != nil { if err := runBootTestPayload([]string{"-fill-blank-passwords", "-games", filepath.Join(dir, "games"), "-out", out, "demo"}); err != nil {
t.Fatal(err) t.Fatal(err)
} }
raw, _ = os.ReadFile(out) raw, _ = os.ReadFile(out)
@@ -73,10 +75,7 @@ configSchema:
if err := json.Unmarshal(raw, &payload); err != nil { if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if s, _ := payload.GameConfig["RCON_PASSWORD"].(string); !strings.HasPrefix(s, "boot-") { if s, _ := payload.GameConfig["ServerPassword"].(string); !strings.HasPrefix(s, "boot-") {
t.Errorf("as deployed: x-generate RCON_PASSWORD not filled: %v", payload.GameConfig["RCON_PASSWORD"]) t.Errorf("-fill-blank-passwords: ServerPassword not filled: %v", payload.GameConfig["ServerPassword"])
}
if payload.GameConfig["ServerPassword"] != "" {
t.Errorf("as deployed: ServerPassword should stay empty, got %v", payload.GameConfig["ServerPassword"])
} }
} }
+11 -11
View File
@@ -236,7 +236,7 @@ func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, ver
if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil { if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil {
return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out) return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out)
} }
output, passed, problems, err := agentBoot(gamesDir, id, agentImage, true) output, passed, problems, err := agentBoot(gamesDir, id, agentImage, false)
if err != nil || problems != nil { if err != nil || problems != nil {
return problems, nil, err return problems, nil, err
} }
@@ -246,7 +246,7 @@ func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, ver
if blank := blankRequiredLooking(gamesDir, id); len(blank) > 0 { if blank := blankRequiredLooking(gamesDir, id); len(blank) > 0 {
fmt.Printf("[build] it failed with the defaults; booting again with %s filled to see whether empty settings are the cause\n", fmt.Printf("[build] it failed with the defaults; booting again with %s filled to see whether empty settings are the cause\n",
strings.Join(blank, ", ")) strings.Join(blank, ", "))
if _, filledPassed, _, err := agentBoot(gamesDir, id, agentImage, false); err == nil && filledPassed { if _, filledPassed, _, err := agentBoot(gamesDir, id, agentImage, true); err == nil && filledPassed {
return []string{emptySettingsProblem(blank, bootVerdict(output))}, nil, nil return []string{emptySettingsProblem(blank, bootVerdict(output))}, nil, nil
} }
} }
@@ -271,10 +271,10 @@ func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, ver
return nil, verified, nil return nil, verified, nil
} }
// agentBoot runs one boot test with the node-agent's BootTest: with the settings a new server gets // agentBoot runs one boot test with the node-agent's BootTest, with the settings a new server gets, like
// (asDeployed), or with blank password-like settings filled with throwaway values as CI's boot test does. // CI. fillBlank also fills blank password-like settings, only to find out whether they are why a draft
// problems is set when the template can't be boot-tested at all. // fails. problems is set when the template can't be boot-tested at all.
func agentBoot(gamesDir, id, agentImage string, asDeployed bool) (output string, passed bool, problems []string, err error) { func agentBoot(gamesDir, id, agentImage string, fillBlank bool) (output string, passed bool, problems []string, err error) {
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
return "", false, nil, err return "", false, nil, err
@@ -284,8 +284,8 @@ func agentBoot(gamesDir, id, agentImage string, asDeployed bool) (output string,
runCmd("docker", "rm", "-f", "boottest-"+id) runCmd("docker", "rm", "-f", "boottest-"+id)
runCmd("docker", "volume", "rm", "-f", "boottest-"+id+"-data") runCmd("docker", "volume", "rm", "-f", "boottest-"+id+"-data")
args := []string{"boottest-payload", "-games", gamesDir, id} args := []string{"boottest-payload", "-games", gamesDir, id}
if asDeployed { if fillBlank {
args = []string{"boottest-payload", "-as-deployed", "-games", gamesDir, id} args = []string{"boottest-payload", "-fill-blank-passwords", "-games", gamesDir, id}
} }
payloadCmd := exec.Command(self, args...) payloadCmd := exec.Command(self, args...)
var stderr bytes.Buffer var stderr bytes.Buffer
@@ -296,7 +296,7 @@ func agentBoot(gamesDir, id, agentImage string, asDeployed bool) (output string,
if errors.As(err, &exitErr) && exitErr.ExitCode() == exitMissingSecret { if errors.As(err, &exitErr) && exitErr.ExitCode() == exitMissingSecret {
// The builder has no CI secrets: that's the template's problem to fix, not a reason to stop // The builder has no CI secrets: that's the template's problem to fix, not a reason to stop
return "", false, []string{"the boot test can't run: " + strings.TrimSpace(stderr.String()) + ". Remove 'secrets' from " + return "", false, []string{"the boot test can't run: " + strings.TrimSpace(stderr.String()) + ". Remove 'secrets' from " +
"test.yaml; boot tests fill password settings with a throwaway value"}, nil "test.yaml; boot tests run with exactly what a new server gets"}, nil
} }
return "", false, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String())) return "", false, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String()))
} }
@@ -317,8 +317,8 @@ func agentBoot(gamesDir, id, agentImage string, asDeployed bool) (output string,
return out.String(), true, nil, nil return out.String(), true, nil, nil
} }
// blankRequiredLooking lists the settings a new server gets empty that CI's boot test would fill with a // blankRequiredLooking lists the settings a new server gets empty that look like a server might need a
// throwaway value: password-like keys with an empty default and no x-generate. // value: password-like keys with an empty default and no x-generate.
func blankRequiredLooking(gamesDir, id string) []string { func blankRequiredLooking(gamesDir, id string) []string {
g, err := loadMap(filepath.Join(gamesDir, id, "game.yaml")) g, err := loadMap(filepath.Join(gamesDir, id, "game.yaml"))
if err != nil { if err != nil {
+1 -1
View File
@@ -276,7 +276,7 @@ func draftCompleteness(game, test map[string]any) []string {
// Secrets only exist as CI settings a person adds; a draft with one can't be boot-tested anywhere // Secrets only exist as CI settings a person adds; a draft with one can't be boot-tested anywhere
if secrets, _ := test["secrets"].(map[string]any); len(secrets) > 0 { if secrets, _ := test["secrets"].(map[string]any); len(secrets) > 0 {
problems = append(problems, "test.yaml has 'secrets' ("+strings.Join(sortedKeys(secrets), ", ")+"), which no boot test of a "+ problems = append(problems, "test.yaml has 'secrets' ("+strings.Join(sortedKeys(secrets), ", ")+"), which no boot test of a "+
"new game has: remove them. Password settings get a throwaway value in boot tests automatically") "new game has: remove them. A setting the server needs a password in gets \"x-generate\" instead")
} }
if _, ok := game["readyPattern"]; !ok { if _, ok := game["readyPattern"]; !ok {
if _, ok := test["settleSec"]; !ok { if _, ok := test["settleSec"]; !ok {
+3 -2
View File
@@ -168,8 +168,9 @@ func validateGame(dir, folder string, gameSchema, testSchema *jsonschema.Schema)
add("test.yaml config.%s is not a key of this game", key) add("test.yaml config.%s is not a key of this game", key)
} }
if sensitiveKey.MatchString(key) { if sensitiveKey.MatchString(key) {
add("test.yaml config.%s looks like a credential; remove it (boot tests fill password settings with a throwaway "+ add("test.yaml config.%s looks like a credential; remove it. A new server starts without it, and the boot test "+
"value), or, only if the server can't start without a real one, map it under 'secrets' to a CI secret", key) "does too: if the server can't, give the setting \"x-generate\" (a password per server). Map it under "+
"'secrets' to a CI secret only for a value that must be real (a license or account ID)", key)
} }
} }
for _, key := range sortedKeys(secrets) { for _, key := range sortedKeys(secrets) {