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"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"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
|
// 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.
|
// 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) {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
|
output, passed, problems, err := agentBoot(gamesDir, id, agentImage, true)
|
||||||
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
|
if err != nil || problems != nil {
|
||||||
"org.springframework.boot.loader.launch.PropertiesLauncher")
|
return problems, nil, err
|
||||||
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
|
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 {
|
if wantOutside {
|
||||||
fmt.Println("[build] first boot wrote saves outside the volume; booting the draft to see that it no longer does")
|
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
|
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) {
|
func firstBootGame(gamesDir, id string) (*bootReport, error) {
|
||||||
outDir := filepath.Join("firstboot-out", id+"-draft")
|
outDir := filepath.Join("firstboot-out", id+"-draft")
|
||||||
if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-out", outDir}); err != nil {
|
if err := runFirstBoot([]string{"-games", gamesDir, "-game", id, "-out", outDir}); err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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