From 9f6e26bc0c859451cce2f54e982a02c381ce31bb Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 25 Oct 2026 23:03:03 -0400 Subject: [PATCH] feat(builder): SteamCMD first for Linux servers, and fall back to the next image 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 --- cmd/gbrgames/build.go | 102 ++++++++++++++++++++++++-------- cmd/gbrgames/research.go | 106 ++++++++++++++++++++++------------ cmd/gbrgames/research_test.go | 49 ++++++++++++++++ 3 files changed, 196 insertions(+), 61 deletions(-) diff --git a/cmd/gbrgames/build.go b/cmd/gbrgames/build.go index 5cc1d4e..199d2aa 100644 --- a/cmd/gbrgames/build.go +++ b/cmd/gbrgames/build.go @@ -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 { diff --git a/cmd/gbrgames/research.go b/cmd/gbrgames/research.go index deae108..9f8a296 100644 --- a/cmd/gbrgames/research.go +++ b/cmd/gbrgames/research.go @@ -173,9 +173,10 @@ const researchPrompt = "You research how to host a game's dedicated server for G "in Docker on Linux, as a non-root user, with a volume at /server. From the search results and pages given, list " + "the ways this game's dedicated server can be run (variants): separate editions of the game with their own server, " + "crossplay builds, or notably different server images. For each variant give:\n" + - "- images: Docker Hub images that run this server, best first (prefer images that run as a non-root user, and the " + - "plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " + - "appear in the pages.\n" + + "- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " + + "cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " + + "variant is the recommended one. Then Docker Hub images that appear in the pages and run as a non-root user " + + "(needed for servers that only exist for Windows).\n" + "- steamAppId: the Steam app id of the DEDICATED SERVER (not the game itself), if installed via SteamCMD; else \"\".\n" + "- startupHint: the command that starts the server from its install folder /server, with the launch options " + "for name, port, world and save location exactly as the pages document them; \"\" if unknown.\n" + @@ -299,13 +300,13 @@ func trimToJSON(answer string) string { // Steam apps must exist and be readable anonymously (what SteamCMD installs need). What fails is dropped // and noted; a variant left with nothing runnable can't be recommended. func verifyVariants(res *researchResult) { - var apps []string + var appIDs []string for _, v := range res.Variants { if v.SteamAppID != "" { - apps = append(apps, v.SteamAppID) + appIDs = append(appIDs, v.SteamAppID) } } - names := steamAppNames(apps) + apps := steamApps(appIDs) anyRunnable := false for i := range res.Variants { v := &res.Variants[i] @@ -324,10 +325,11 @@ func verifyVariants(res *researchResult) { } v.Images = images if v.SteamAppID != "" { - if name, ok := names[v.SteamAppID]; ok { - v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, name)) - if !hasImage(v.Images, "cm2network/steamcmd") { - v.Images = append(v.Images, "cm2network/steamcmd") + if app, ok := apps[v.SteamAppID]; ok { + v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name)) + v.Images = withSteamCMD(v.Images, app.linux) + if app.linux { + v.Verified = append(v.Verified, "it ships a Linux server, so SteamCMD (installing it into /server) is tried first") } } else { v.Problems = append(v.Problems, "Steam app "+v.SteamAppID+" not found for anonymous SteamCMD") @@ -346,44 +348,56 @@ func verifyVariants(res *researchResult) { res.Reason = "unknown" res.Message = "No variant has a Docker image or Steam app that could be confirmed. " + res.Message } - // Exactly one recommendation, among the runnable ones + recommend(res.Variants) +} + +// recommend marks exactly one variant recommended, among the runnable ones. A variant that installs a Linux +// server with SteamCMD comes before the model's choice: it keeps everything under /server, as nodes need. +func recommend(variants []variant) { + runnable := func(v variant) bool { return len(v.Images) > 0 } + steamFirst := func(v variant) bool { return runnable(v) && isSteamCMDImage(v.Images[0]) } rec := -1 - for i, v := range res.Variants { - if v.Recommended && rec < 0 && len(v.Images) > 0 { - rec = i - } - res.Variants[i].Recommended = false - } - if rec < 0 { - for i, v := range res.Variants { - if len(v.Images) > 0 { + for _, want := range []func(variant) bool{ + func(v variant) bool { return v.Recommended && steamFirst(v) }, + steamFirst, + func(v variant) bool { return v.Recommended && runnable(v) }, + runnable, + } { + for i, v := range variants { + if rec < 0 && want(v) { rec = i - break } } } - if rec >= 0 { - res.Variants[rec].Recommended = true + for i := range variants { + variants[i].Recommended = i == rec } } -func hasImage(images []string, name string) bool { - for _, i := range images { - if i == name || strings.HasPrefix(i, name+":") { - return true - } - } - return false +var ( + appName = regexp.MustCompile(`"common"\s*\{[^}]*?"name"\s+"([^"]+)"`) + appLinux = regexp.MustCompile(`"oslist"\s+"[^"]*linux`) +) + +type steamApp struct { + name string + linux bool // ships a Linux server: SteamCMD installs it straight into /server } -var appName = regexp.MustCompile(`"common"\s*\{[^}]*?"name"\s+"([^"]+)"`) +func parseAppInfo(out string) (steamApp, bool) { + m := appName.FindStringSubmatch(out) + if m == nil { + return steamApp{}, false + } + return steamApp{name: m[1], linux: appLinux.MatchString(out)}, true +} -// steamAppNames asks SteamCMD (anonymously, in its official image) for each app's info: an app that +// steamApps asks SteamCMD (anonymously, in its official image) for each app's info: an app that // anonymous SteamCMD can't see can't be installed by the node-agent either. -func steamAppNames(apps []string) map[string]string { - names := map[string]string{} +func steamApps(apps []string) map[string]steamApp { + found := map[string]steamApp{} if len(apps) == 0 { - return names + return found } for _, app := range apps { if !regexp.MustCompile(`^\d+$`).MatchString(app) { @@ -395,9 +409,25 @@ func steamAppNames(apps []string) map[string]string { fmt.Printf("[research] steamcmd for app %s: %v\n", app, err) continue } - if m := appName.FindStringSubmatch(out); m != nil { - names[app] = m[1] + if info, ok := parseAppInfo(out); ok { + found[app] = info } } - return names + return found +} + +// withSteamCMD adds the plain SteamCMD route to a variant's images. When the Steam app ships a Linux server +// it goes first: it installs into /server, which nodes keep, while ready-made images often keep their +// files elsewhere. For Windows-only servers it stays last, behind images that bring Proton or Wine. +func withSteamCMD(images []string, linux bool) []string { + var others []string + for _, i := range images { + if !isSteamCMDImage(i) { + others = append(others, i) + } + } + if linux { + return append([]string{"cm2network/steamcmd"}, others...) + } + return append(others, "cm2network/steamcmd") } diff --git a/cmd/gbrgames/research_test.go b/cmd/gbrgames/research_test.go index 9f39018..0da63a8 100644 --- a/cmd/gbrgames/research_test.go +++ b/cmd/gbrgames/research_test.go @@ -39,3 +39,52 @@ func TestAppNameFromAppInfo(t *testing.T) { t.Errorf("got %v", m) } } + +func TestParseAppInfoFindsLinuxServers(t *testing.T) { + linux, ok := parseAppInfo(`"1000000" +{ + "common" + { + "name" "Example Dedicated Server" + "oslist" "windows,linux" + } +}`) + if !ok || linux.name != "Example Dedicated Server" || !linux.linux { + t.Errorf("got %+v %v", linux, ok) + } + // A server that only exists for Windows (needs Proton or Wine) + windows, _ := parseAppInfo(`"common" { "name" "Example Server" "oslist" "windows" }`) + if windows.linux { + t.Error("windows-only app reported as linux") + } +} + +func TestWithSteamCMDGoesFirstOnlyForLinuxServers(t *testing.T) { + images := []string{"someone/example-server", "cm2network/steamcmd", "other/example"} + got := withSteamCMD(images, true) + if len(got) != 3 || got[0] != "cm2network/steamcmd" || got[1] != "someone/example-server" { + t.Errorf("linux: %v", got) + } + got = withSteamCMD([]string{"someone/proton-example"}, false) + if len(got) != 2 || got[1] != "cm2network/steamcmd" { + t.Errorf("windows-only: %v", got) + } +} + +func TestRecommendPrefersTheSteamCMDVariant(t *testing.T) { + v := []variant{ + {ID: "image", Recommended: true, Images: []string{"someone/example-server"}}, + {ID: "steam", Images: []string{"cm2network/steamcmd", "someone/example-server"}}, + {ID: "broken", Recommended: true}, + } + recommend(v) + if v[0].Recommended || !v[1].Recommended || v[2].Recommended { + t.Errorf("got %+v", v) + } + // Without a SteamCMD variant, the model's runnable choice stands + v = []variant{{ID: "a", Images: []string{"x/a"}}, {ID: "b", Recommended: true, Images: []string{"x/b"}}} + recommend(v) + if v[0].Recommended || !v[1].Recommended { + t.Errorf("got %+v", v) + } +}