feat(builder): SteamCMD first for Linux servers, and fall back to the next image
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped

A draft built on a ready-made image whose files all live outside /server
(nodes keep only /server and /data) went round three times and ended as
WIP, although research had also verified the game's Steam app, which
SteamCMD installs straight into /server. The builder never tried it.

- research reads each Steam app's OS list. When it ships a Linux server,
  cm2network/steamcmd goes first in the variant's images and that variant
  is recommended. Windows-only servers keep images with Proton or Wine
  first. The research prompt says the same.
- build tries the variant's images in order: when no draft passes with one,
  the next image gets a fresh start. The first draft that boots wins; if
  none does, the PR gets the draft that got furthest, and its summary lists
  each image tried and how it went.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-25 23:03:03 -04:00
1 parent 274ed68cc5
commit 9f6e26bc0c
3 files changed
+196 -61

No files matched your search

+79 -23
View File
@@ -27,6 +27,7 @@ type buildResult struct {
Verified []string `json:"verified"`
Problems []string `json:"problems"`
Summary string `json:"summary"`
Tried []string `json:"tried,omitempty"` // each image tried, and how it went
}
const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
@@ -93,26 +94,81 @@ type buildOptions struct {
agentImage string
}
// build tries the variant's images in order (research puts SteamCMD first when the game ships a Linux
// server) and keeps the first draft that boots. A draft that can't be made to work with one image is set
// aside and the next image gets a fresh start; if none works, the draft that got furthest is returned.
func build(o buildOptions) (*buildResult, error) {
if len(o.variant.Images) == 0 {
return nil, errors.New("the variant has no image to build from")
}
res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id)}
res.Verified = append(res.Verified, o.variant.Verified...)
// 1. First boot of each candidate image until one runs as a non-root user
var report *bootReport
var candidates []string
for _, image := range o.variant.Images {
steam := isSteamCMDImage(image) && o.variant.SteamAppID != ""
if isSteamCMDImage(image) && !steam {
if isSteamCMDImage(image) && o.variant.SteamAppID == "" {
continue // the SteamCMD image alone runs nothing
}
res.Image = image
if steam && o.variant.StartupHint == "" {
fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID)
report = nil
break
candidates = append(candidates, image)
}
if len(candidates) == 0 {
return nil, errors.New("the variant has no image to build from")
}
dir := filepath.Join(o.gamesDir, o.id)
setAside := filepath.Join("firstboot-out", o.id+"-best-draft")
var best *buildResult
var tried []string
for i, image := range candidates {
if i > 0 {
fmt.Printf("[build] trying the next image: %s\n", image)
}
res, err := buildWithImage(o, image)
if err != nil {
return res, err
}
tried = append(tried, attemptNote(res))
if res.Passed {
res.Tried = tried
res.Summary = buildSummary(o, res)
return res, nil
}
if _, err := os.Stat(filepath.Join(dir, "game.yaml")); err == nil {
// This one got a draft written: keep it in case no later image does better
os.RemoveAll(setAside)
if err := os.Rename(dir, setAside); err != nil {
return res, err
}
best = res
}
}
if best == nil {
return &buildResult{GameID: o.id, Dir: dir, Tried: tried, Problems: []string{"no image of this variant could be drafted"}},
errors.New("no image of this variant could be drafted: " + strings.Join(tried, "; "))
}
if err := os.Rename(setAside, dir); err != nil {
return best, err
}
best.Tried = tried
best.Summary = buildSummary(o, best)
return best, nil
}
func attemptNote(res *buildResult) string {
switch {
case res.Passed:
return res.Image + ": booted"
case len(res.Problems) > 0:
return res.Image + ": " + firstLine(res.Problems[0])
}
return res.Image + ": did not boot"
}
// buildWithImage drafts the template for one image: first boot, draft, then boots of the draft with
// failures going back to the model.
func buildWithImage(o buildOptions, image string) (*buildResult, error) {
res := &buildResult{GameID: o.id, Dir: filepath.Join(o.gamesDir, o.id), Image: image}
res.Verified = append(res.Verified, o.variant.Verified...)
steam := isSteamCMDImage(image)
// 1. First boot of the image untouched: its log, files and settings are the draft's facts
var report *bootReport
if steam && o.variant.StartupHint == "" {
fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID)
} else {
fmt.Printf("[build] first boot of %s\n", image)
r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint)
if err != nil {
@@ -120,18 +176,12 @@ func build(o buildOptions) (*buildResult, error) {
}
if why := imageUnusable(r); why != "" {
fmt.Printf("[build] %s can't be used: %s\n", image, why)
res.Problems = append(res.Problems, why)
res.Image = ""
continue
res.Problems = []string{why}
return res, nil
}
report = r
res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome))
break
}
if res.Image == "" {
return res, errors.New("no image of this variant runs as a non-root user")
}
res.Problems = nil
// 2. Draft until validation and the image checks pass
steamApp := ""
@@ -411,6 +461,12 @@ func buildSummary(o buildOptions, res *buildResult) string {
fmt.Fprintf(&b, ", Steam app %s", o.variant.SteamAppID)
}
fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds)
if len(res.Tried) > 1 {
b.WriteString("\n**Images tried, in order**\n")
for _, t := range res.Tried {
fmt.Fprintf(&b, "- %s\n", firstLine(t))
}
}
if len(res.Verified) > 0 {
b.WriteString("\n**Checked for real**\n")
for _, v := range res.Verified {