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 <noreply@anthropic.com>
This commit is contained in:
1 parent
cc4a8e357d
commit
98ba043bec
2 files changed
+121
-35
No files matched your search
+92
-35
@@ -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
|
||||
output, passed, problems, err := agentBoot(gamesDir, id, agentImage, true)
|
||||
if err != nil || problems != nil {
|
||||
return problems, nil, err
|
||||
}
|
||||
return []string{bootFailure(output.String())}, nil, nil
|
||||
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
|
||||
}
|
||||
verified = append(verified, "boot test with the node-agent: "+bootVerdict(output.String()))
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user