feat(builder): pinpoint settings a server can't start without
Validate game templates / validate (push) Successful in 20s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

Booting drafts as deployed catches a server that stops when a setting is
empty, but the server's own output rarely says why, so the model guessed
(more memory) for three rounds and the draft ended as WIP.

When a draft fails with its defaults and has blank password-like settings
without x-generate, the builder boots it once more with those filled. If
that boot passes, the cause is certain, and the model is told exactly that:
these settings stop the server when empty; give them x-generate (a password
per server) or a working default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 21:57:16 -04:00
1 parent cc4a8e357d
commit 98ba043bec
2 files changed
+122 -36

No files matched your search

+93 -36
View File
@@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
@@ -232,46 +233,26 @@ func firstBoot(name, image, steamApp string, steam bool, startup string) (*bootR
// bootCheck deploys the template with the node-agent's BootTest, the same check CI runs, and when the
// game wrote files outside its volume on the first boot, boots it once more to see that it no longer does.
func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, verified []string, err error) {
self, err := os.Executable()
if err != nil {
return nil, nil, err
}
// A boot test that was killed (worker restarted mid-build) leaves its container and volume behind,
// and the next deploy under the same name would trip over them
runCmd("docker", "rm", "-f", "boottest-"+id)
runCmd("docker", "volume", "rm", "-f", "boottest-"+id+"-data")
payloadCmd := exec.Command(self, "boottest-payload", "-as-deployed", "-games", gamesDir, id)
var stderr bytes.Buffer
payloadCmd.Stderr = &stderr
payload, err := payloadCmd.Output()
if err != nil {
var exitErr *exec.ExitError
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
return []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, nil
}
return nil, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String()))
}
if out, err := runCmd("docker", "pull", "-q", agentImage); err != nil {
return nil, nil, fmt.Errorf("docker pull %s: %v: %s", agentImage, err, out)
}
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
"org.springframework.boot.loader.launch.PropertiesLauncher")
cmd.Stdin = bytes.NewReader(payload)
var output bytes.Buffer
cmd.Stdout = &teeWriter{&output}
cmd.Stderr = cmd.Stdout
runErr := cmd.Run()
if runErr != nil {
var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) {
return nil, nil, runErr
}
return []string{bootFailure(output.String())}, nil, nil
output, passed, problems, err := agentBoot(gamesDir, id, agentImage, true)
if err != nil || problems != nil {
return problems, nil, err
}
verified = append(verified, "boot test with the node-agent: "+bootVerdict(output.String()))
if !passed {
// The server's own output rarely says it stopped because a setting was empty. Booting once more
// with the blank ones filled tells for certain whether they are the cause.
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",
strings.Join(blank, ", "))
if _, filledPassed, _, err := agentBoot(gamesDir, id, agentImage, false); err == nil && filledPassed {
return []string{emptySettingsProblem(blank, bootVerdict(output))}, nil, nil
}
}
return []string{bootFailure(output)}, nil, nil
}
verified = append(verified, "boot test with the node-agent: "+bootVerdict(output))
if wantOutside {
fmt.Println("[build] first boot wrote saves outside the volume; booting the draft to see that it no longer does")
@@ -290,6 +271,82 @@ func bootCheck(gamesDir, id, agentImage string, wantOutside bool) (problems, ver
return nil, verified, nil
}
// agentBoot runs one boot test with the node-agent's BootTest: with the settings a new server gets
// (asDeployed), or with blank password-like settings filled with throwaway values as CI's boot test does.
// 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) {
self, err := os.Executable()
if err != nil {
return "", false, nil, err
}
// A boot test that was killed (worker restarted mid-build) leaves its container and volume behind,
// and the next deploy under the same name would trip over them
runCmd("docker", "rm", "-f", "boottest-"+id)
runCmd("docker", "volume", "rm", "-f", "boottest-"+id+"-data")
args := []string{"boottest-payload", "-games", gamesDir, id}
if asDeployed {
args = []string{"boottest-payload", "-as-deployed", "-games", gamesDir, id}
}
payloadCmd := exec.Command(self, args...)
var stderr bytes.Buffer
payloadCmd.Stderr = &stderr
payload, err := payloadCmd.Output()
if err != nil {
var exitErr *exec.ExitError
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
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
}
return "", false, nil, fmt.Errorf("boottest-payload: %v: %s", err, strings.TrimSpace(stderr.String()))
}
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
"org.springframework.boot.loader.launch.PropertiesLauncher")
cmd.Stdin = bytes.NewReader(payload)
var out bytes.Buffer
cmd.Stdout = &teeWriter{&out}
cmd.Stderr = cmd.Stdout
if runErr := cmd.Run(); runErr != nil {
var exitErr *exec.ExitError
if !errors.As(runErr, &exitErr) {
return "", false, nil, runErr
}
return out.String(), false, 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
// throwaway value: password-like keys with an empty default and no x-generate.
func blankRequiredLooking(gamesDir, id string) []string {
g, err := loadMap(filepath.Join(gamesDir, id, "game.yaml"))
if err != nil {
return nil
}
defaults, _ := g["defaultConfig"].(map[string]any)
var keys []string
for key, p := range schemaProperties(g) {
prop, _ := p.(map[string]any)
if prop["x-generate"] != nil || !passwordKey.MatchString(key) {
continue
}
if v, ok := defaults[key]; !ok || v == nil || v == "" {
keys = append(keys, key)
}
}
sort.Strings(keys)
return keys
}
func emptySettingsProblem(keys []string, verdict string) string {
return fmt.Sprintf("the server stops when %s is empty, as it is for every new server: booted with your defaults it "+
"failed (%s), and booted again with a value in %s it ran. A new server must start without the owner typing "+
"anything: give %s \"x-generate\": {\"type\": \"password\"} in configSchema (the panel then makes a password for "+
"each server; keep the default \"\"), or a default value that works. Keep the setting so owners can change it",
strings.Join(keys, ", "), verdict, strings.Join(keys, ", "), strings.Join(keys, ", "))
}
func firstBootGame(gamesDir, id string) (*bootReport, error) {
outDir := filepath.Join("firstboot-out", id+"-draft")
if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-out", outDir}); err != nil {
+29
View File
@@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -48,3 +50,30 @@ func TestVariantNotes(t *testing.T) {
}
}
}
func TestBlankRequiredLookingSettings(t *testing.T) {
dir := t.TempDir()
game := filepath.Join(dir, "example")
os.MkdirAll(game, 0o755)
os.WriteFile(filepath.Join(game, "game.yaml"), []byte(`id: example
defaultConfig:
JoinPassword: ""
AdminPassword: ""
ServerPassword: set-by-default
ServerName: x
configSchema:
properties:
JoinPassword: {type: string}
AdminPassword: {type: string, x-generate: {type: password}}
ServerPassword: {type: string}
ServerName: {type: string}
`), 0o644)
got := blankRequiredLooking(dir, "example")
if len(got) != 1 || got[0] != "JoinPassword" {
t.Errorf("got %v, want only JoinPassword (AdminPassword is generated, ServerPassword has a default)", got)
}
msg := emptySettingsProblem(got, "FAILED: server crashed")
if !strings.Contains(msg, "stops when JoinPassword is empty") || !strings.Contains(msg, `"x-generate": {"type": "password"}`) {
t.Errorf("message: %s", msg)
}
}