Compare commits

..
1 Commits
Author SHA1 Message Date
gregandClaude Opus 5.5 9f6e26bc0c 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>
2026-10-25 23:03:03 -04:00
3 changed files with 61 additions and 67 deletions

No files matched your search

+60 -7
View File
@@ -27,6 +27,7 @@ type buildResult struct {
Verified []string `json:"verified"` Verified []string `json:"verified"`
Problems []string `json:"problems"` Problems []string `json:"problems"`
Summary string `json:"summary"` 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" const defaultAgentImage = "www.gitea.gbreed.com/gbreed-public/gbr-node-agent:latest"
@@ -93,21 +94,67 @@ type buildOptions struct {
agentImage string agentImage string
} }
// build drafts the template for exactly the option the admin chose: one image (research lists each way to // build tries the variant's images in order (research puts SteamCMD first when the game ships a Linux
// run a game as its own option). It never switches to another image; if the chosen one can't be made to // server) and keeps the first draft that boots. A draft that can't be made to work with one image is set
// work, the result says so and the admin can pick a different option. // 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) { func build(o buildOptions) (*buildResult, error) {
var candidates []string
for _, image := range o.variant.Images { for _, image := range o.variant.Images {
if isSteamCMDImage(image) && o.variant.SteamAppID == "" { if isSteamCMDImage(image) && o.variant.SteamAppID == "" {
continue // the SteamCMD image alone runs nothing continue // the SteamCMD image alone runs nothing
} }
res, err := buildWithImage(o, image) candidates = append(candidates, image)
if res != nil {
res.Summary = buildSummary(o, res)
} }
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 return res, err
} }
return nil, errors.New("the variant has no image to build from") 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 // buildWithImage drafts the template for one image: first boot, draft, then boots of the draft with
@@ -414,6 +461,12 @@ func buildSummary(o buildOptions, res *buildResult) string {
fmt.Fprintf(&b, ", Steam app %s", o.variant.SteamAppID) fmt.Fprintf(&b, ", Steam app %s", o.variant.SteamAppID)
} }
fmt.Fprintf(&b, "\n- Model rounds: %d\n", res.Rounds) 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 { if len(res.Verified) > 0 {
b.WriteString("\n**Checked for real**\n") b.WriteString("\n**Checked for real**\n")
for _, v := range res.Verified { for _, v := range res.Verified {
+1 -36
View File
@@ -329,7 +329,7 @@ func verifyVariants(res *researchResult) {
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name)) v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name))
v.Images = withSteamCMD(v.Images, app.linux) v.Images = withSteamCMD(v.Images, app.linux)
if app.linux { if app.linux {
v.Verified = append(v.Verified, "it ships a Linux server, so the SteamCMD option (installing it into /server) is recommended") v.Verified = append(v.Verified, "it ships a Linux server, so SteamCMD (installing it into /server) is tried first")
} }
} else { } else {
v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD")
@@ -348,7 +348,6 @@ func verifyVariants(res *researchResult) {
res.Reason = "unknown" res.Reason = "unknown"
res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message
} }
res.Variants = oneOptionPerImage(res.Variants)
recommend(res.Variants) recommend(res.Variants)
} }
@@ -432,37 +431,3 @@ func withSteamCMD(images []string, linux bool) []string {
} }
return append(others, "cm2network/steamcmd") return append(others, "cm2network/steamcmd")
} }
// oneOptionPerImage turns a variant that can run on several images into one choice per image, so the
// admin picks exactly how the server runs and the build uses that and nothing else.
func oneOptionPerImage(variants []variant) []variant {
var out []variant
for _, v := range variants {
if len(v.Images) <= 1 {
out = append(out, v)
continue
}
for _, img := range v.Images {
o := v
o.Images = []string{img}
o.Verified = nil
for _, line := range v.Verified {
if !strings.HasPrefix(line, "image ") || line == "image "+img+" exists" {
o.Verified = append(o.Verified, line)
}
}
if isSteamCMDImage(img) {
o.ID = v.ID + "-steamcmd"
o.Title = v.Title + " (SteamCMD)"
o.Description = strings.TrimSpace(v.Description + " Installed with SteamCMD into the server volume.")
} else {
o.ID = v.ID + "-" + gameID(img)
o.Title = v.Title + " (image " + img + ")"
o.Description = strings.TrimSpace(v.Description + " Runs in the ready-made image " + img + ".")
o.SteamAppID = ""
}
out = append(out, o)
}
}
return out
}
-24
View File
@@ -88,27 +88,3 @@ func TestRecommendPrefersTheSteamCMDVariant(t *testing.T) {
t.Errorf("got %+v", v) t.Errorf("got %+v", v)
} }
} }
func TestOneOptionPerImageThenSteamCMDRecommended(t *testing.T) {
v := oneOptionPerImage([]variant{{
ID: "dedicated", Title: "Example Dedicated Server", Recommended: true, SteamAppID: "1000000",
Images: withSteamCMD([]string{"someone/example-server"}, true),
Verified: []string{"image someone/example-server exists", "Steam app 1000000 is \"Example Dedicated Server\""},
}})
recommend(v)
if len(v) != 2 {
t.Fatalf("want one option per image, got %+v", v)
}
steam, image := v[0], v[1]
if steam.Title != "Example Dedicated Server (SteamCMD)" || !steam.Recommended || steam.SteamAppID != "1000000" || len(steam.Images) != 1 {
t.Errorf("SteamCMD option: %+v", steam)
}
if image.Title != "Example Dedicated Server (image someone/example-server)" || image.Recommended || image.SteamAppID != "" {
t.Errorf("image option: %+v", image)
}
for _, line := range steam.Verified {
if line == "image someone/example-server exists" {
t.Error("SteamCMD option lists the other option's image check")
}
}
}