From 98ba043bec4f8039cb49ad9121a268aa0da65ece Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 21:57:16 -0400 Subject: [PATCH] feat(builder): pinpoint settings a server can't start without 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 --- cmd/gbrgames/build.go | 129 ++++++++++++++++++++++++++----------- cmd/gbrgames/build_test.go | 29 +++++++++ 2 files changed, 122 insertions(+), 36 deletions(-) diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index fd26077..5e61f27 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -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 { diff --git a/cmd/gbrgames/build_test.go b/cmd/gbrgames/build_test.go index f31f6cb..7f6a0c3 100644 --- a/cmd/gbrgames/build_test.go +++ b/cmd/gbrgames/build_test.go @@ -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) + } +}