feat(builder): every boot through the node-agent, every lost file counted, Steam cache

What a builder boot sees is now what a node does:
- First boots (untouched, or -as-deployed with exactly a new server's
  settings) are deployed by the node-agent's BootTest in keep mode; the
  builder only watches the console and inspects what's left. The Go copy of
  the deploy (its own docker run, mounts and startup prefix) is gone. It had
  drifted: it gave the image's VOLUME folders their own volumes, which nodes
  don't, so saves there looked safe while every start loses them.
- Files written outside /server and /data are counted whatever their type
  (it only counted config-like extensions, so .sav or .db saves went
  unseen), including the image's VOLUME folders; logs, pid and lock files
  and system folders are left out.
- Boot tests ask the server to stay up holdSec (60 s, test.yaml can set it)
  after its ready line and to listen on every published container port
  (needs the node-agent's matching BootTest).
- Steam apps are downloaded once per build into a gbr.cache volume and copied
  into each boot, so later boots only check the files (STEAM_CACHE=off).
- RUNNER.md no longer suggests docker system prune, which deletes the
  gbr-boottest network when nothing is using it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-26 00:00:50 -04:00
1 parent e07025f03d
commit 04967a8a16
10 files changed
+424 -171

No files matched your search

+54 -26
View File
@@ -35,21 +35,53 @@ func runBootTestPayload(args []string) error {
fillBlank := fs.Bool("fill-blank-passwords", false, "also give blank password settings a throwaway value. Never for a "+
"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")
keep := fs.Bool("keep", false, "leave the container and its volume after the test, for inspection")
fs.Parse(args)
if fs.NArg() != 1 {
return errors.New("boottest-payload needs a game id")
}
id := fs.Arg(0)
dir := filepath.Join(*gamesDir, id)
g, err := loadMap(filepath.Join(dir, "game.yaml"))
payload, err := bootTestPayload(*gamesDir, fs.Arg(0), *fillBlank)
var missing *missingSecretError
if errors.As(err, &missing) {
fmt.Fprintln(os.Stderr, "skip: "+missing.Error())
os.Exit(exitMissingSecret)
}
if err != nil {
return err
}
if *keep {
payload["keepContainer"] = true
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if *out == "" {
_, err = os.Stdout.Write(b)
return err
}
return os.WriteFile(*out, b, 0o644)
}
type missingSecretError struct{ game, secret, key string }
func (e *missingSecretError) Error() string {
return fmt.Sprintf("%s needs secret %s for %s", e.game, e.secret, e.key)
}
// bootTestPayload builds the DEPLOY_SERVER message the panel would send for a game, plus the boot-test
// settings BootTest reads: ready and fail patterns, timeouts, how long the server must stay up after it is
// ready, and the container ports it must then listen on.
func bootTestPayload(gamesDir, id string, fillBlank bool) (map[string]any, error) {
dir := filepath.Join(gamesDir, id)
g, err := loadMap(filepath.Join(dir, "game.yaml"))
if err != nil {
return nil, err
}
test := map[string]any{}
if _, err := os.Stat(filepath.Join(dir, "test.yaml")); err == nil {
if test, err = loadMap(filepath.Join(dir, "test.yaml")); err != nil {
return err
return nil, err
}
}
@@ -68,8 +100,7 @@ func runBootTestPayload(args []string) error {
for key, name := range s {
value, ok := os.LookupEnv(fmt.Sprint(name))
if !ok || value == "" {
fmt.Fprintf(os.Stderr, "skip: %s needs secret %s for %s\n", id, name, key)
os.Exit(exitMissingSecret)
return nil, &missingSecretError{game: id, secret: fmt.Sprint(name), key: key}
}
config[key] = value
}
@@ -79,7 +110,7 @@ func runBootTestPayload(args []string) error {
// one: they passed CI and failed on the first real deploy.
for key, p := range schemaProperties(g) {
prop, _ := p.(map[string]any)
if prop["x-generate"] == nil && (!*fillBlank || !passwordKey.MatchString(key)) {
if prop["x-generate"] == nil && (!fillBlank || !passwordKey.MatchString(key)) {
continue
}
// Only missing or empty text: ports, booleans and other values are never touched
@@ -93,22 +124,26 @@ func runBootTestPayload(args []string) error {
}
}
timeout, settle := 900, 120
timeout, settle, hold := 900, 120, 60
if g["installMethod"] == "STEAMCMD" {
timeout = 2400
}
if v, ok := test["timeoutSec"].(json.Number); ok {
if n, err := v.Int64(); err == nil {
timeout = int(n)
for key, dst := range map[string]*int{"timeoutSec": &timeout, "settleSec": &settle, "holdSec": &hold} {
if v, ok := test[key].(json.Number); ok {
if n, err := v.Int64(); err == nil {
*dst = int(n)
}
}
}
if v, ok := test["settleSec"].(json.Number); ok {
if n, err := v.Int64(); err == nil {
settle = int(n)
// Once up, the server must listen on what nodes publish: the container side of every default port
expectPorts := []any{}
if ports, ok := g["defaultPorts"].([]any); ok {
for _, p := range ports {
expectPorts = append(expectPorts, fmt.Sprint(p))
}
}
payload := map[string]any{
return map[string]any{
"command": "DEPLOY_SERVER",
"serverId": "boottest-" + id,
"containerName": "boottest-" + id,
@@ -130,16 +165,9 @@ func runBootTestPayload(args []string) error {
"failPatterns": g["failPatterns"],
"timeoutSec": timeout,
"settleSec": settle,
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
if *out == "" {
_, err = os.Stdout.Write(b)
return err
}
return os.WriteFile(*out, b, 0o644)
"holdSec": hold,
"expectPorts": expectPorts,
}, nil
}
// resolvePlaceholders stands in for what the panel fills per server; saved values have no owner here.