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 <noreply@anthropic.com>
This commit is contained in:
1 parent
274ed68cc5
commit
9f6e26bc0c
3 files changed
+195
-60
No files matched your search
+78
-22
@@ -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,26 +94,81 @@ type buildOptions struct {
|
|||||||
agentImage string
|
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) {
|
func build(o buildOptions) (*buildResult, error) {
|
||||||
if len(o.variant.Images) == 0 {
|
var candidates []string
|
||||||
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
|
|
||||||
for _, image := range o.variant.Images {
|
for _, image := range o.variant.Images {
|
||||||
steam := isSteamCMDImage(image) && o.variant.SteamAppID != ""
|
if isSteamCMDImage(image) && o.variant.SteamAppID == "" {
|
||||||
if isSteamCMDImage(image) && !steam {
|
|
||||||
continue // the SteamCMD image alone runs nothing
|
continue // the SteamCMD image alone runs nothing
|
||||||
}
|
}
|
||||||
res.Image = image
|
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 == "" {
|
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)
|
fmt.Printf("[build] no startup command known for Steam app %s; drafting without a first boot\n", o.variant.SteamAppID)
|
||||||
report = nil
|
} else {
|
||||||
break
|
|
||||||
}
|
|
||||||
fmt.Printf("[build] first boot of %s\n", image)
|
fmt.Printf("[build] first boot of %s\n", image)
|
||||||
r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint)
|
r, err := firstBoot(o.id+"-probe", image, o.variant.SteamAppID, steam, o.variant.StartupHint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,18 +176,12 @@ func build(o buildOptions) (*buildResult, error) {
|
|||||||
}
|
}
|
||||||
if why := imageUnusable(r); why != "" {
|
if why := imageUnusable(r); why != "" {
|
||||||
fmt.Printf("[build] %s can't be used: %s\n", image, why)
|
fmt.Printf("[build] %s can't be used: %s\n", image, why)
|
||||||
res.Problems = append(res.Problems, why)
|
res.Problems = []string{why}
|
||||||
res.Image = ""
|
return res, nil
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
report = r
|
report = r
|
||||||
res.Verified = append(res.Verified, fmt.Sprintf("first boot of %s: %s", image, r.Outcome))
|
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
|
// 2. Draft until validation and the image checks pass
|
||||||
steamApp := ""
|
steamApp := ""
|
||||||
@@ -411,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 {
|
||||||
|
|||||||
+68
-38
@@ -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 " +
|
"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, " +
|
"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" +
|
"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 " +
|
"- images: how to run this server, best first. When the game has a dedicated server on Steam that runs on Linux, " +
|
||||||
"plain SteamCMD route cm2network/steamcmd when the game ships a Linux dedicated server on Steam). Only images that " +
|
"cm2network/steamcmd (the plain SteamCMD route, installing the server into /server) always comes first and that " +
|
||||||
"appear in the pages.\n" +
|
"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" +
|
"- 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 " +
|
"- 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" +
|
"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
|
// 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.
|
// and noted; a variant left with nothing runnable can't be recommended.
|
||||||
func verifyVariants(res *researchResult) {
|
func verifyVariants(res *researchResult) {
|
||||||
var apps []string
|
var appIDs []string
|
||||||
for _, v := range res.Variants {
|
for _, v := range res.Variants {
|
||||||
if v.SteamAppID != "" {
|
if v.SteamAppID != "" {
|
||||||
apps = append(apps, v.SteamAppID)
|
appIDs = append(appIDs, v.SteamAppID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
names := steamAppNames(apps)
|
apps := steamApps(appIDs)
|
||||||
anyRunnable := false
|
anyRunnable := false
|
||||||
for i := range res.Variants {
|
for i := range res.Variants {
|
||||||
v := &res.Variants[i]
|
v := &res.Variants[i]
|
||||||
@@ -324,10 +325,11 @@ func verifyVariants(res *researchResult) {
|
|||||||
}
|
}
|
||||||
v.Images = images
|
v.Images = images
|
||||||
if v.SteamAppID != "" {
|
if v.SteamAppID != "" {
|
||||||
if name, ok := names[v.SteamAppID]; ok {
|
if app, ok := apps[v.SteamAppID]; ok {
|
||||||
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, name))
|
v.Verified = append(v.Verified, fmt.Sprintf("Steam app %s is %q", v.SteamAppID, app.name))
|
||||||
if !hasImage(v.Images, "cm2network/steamcmd") {
|
v.Images = withSteamCMD(v.Images, app.linux)
|
||||||
v.Images = append(v.Images, "cm2network/steamcmd")
|
if app.linux {
|
||||||
|
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")
|
||||||
@@ -346,44 +348,56 @@ 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
|
||||||
}
|
}
|
||||||
// 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
|
rec := -1
|
||||||
for i, v := range res.Variants {
|
for _, want := range []func(variant) bool{
|
||||||
if v.Recommended && rec < 0 && len(v.Images) > 0 {
|
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
|
rec = i
|
||||||
}
|
}
|
||||||
res.Variants[i].Recommended = false
|
|
||||||
}
|
|
||||||
if rec < 0 {
|
|
||||||
for i, v := range res.Variants {
|
|
||||||
if len(v.Images) > 0 {
|
|
||||||
rec = i
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
for i := range variants {
|
||||||
if rec >= 0 {
|
variants[i].Recommended = i == rec
|
||||||
res.Variants[rec].Recommended = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hasImage(images []string, name string) bool {
|
var (
|
||||||
for _, i := range images {
|
appName = regexp.MustCompile(`"common"\s*\{[^}]*?"name"\s+"([^"]+)"`)
|
||||||
if i == name || strings.HasPrefix(i, name+":") {
|
appLinux = regexp.MustCompile(`"oslist"\s+"[^"]*linux`)
|
||||||
return true
|
)
|
||||||
}
|
|
||||||
}
|
type steamApp struct {
|
||||||
return false
|
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.
|
// anonymous SteamCMD can't see can't be installed by the node-agent either.
|
||||||
func steamAppNames(apps []string) map[string]string {
|
func steamApps(apps []string) map[string]steamApp {
|
||||||
names := map[string]string{}
|
found := map[string]steamApp{}
|
||||||
if len(apps) == 0 {
|
if len(apps) == 0 {
|
||||||
return names
|
return found
|
||||||
}
|
}
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
if !regexp.MustCompile(`^\d+$`).MatchString(app) {
|
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)
|
fmt.Printf("[research] steamcmd for app %s: %v\n", app, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := appName.FindStringSubmatch(out); m != nil {
|
if info, ok := parseAppInfo(out); ok {
|
||||||
names[app] = m[1]
|
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")
|
||||||
}
|
}
|
||||||
@@ -39,3 +39,52 @@ func TestAppNameFromAppInfo(t *testing.T) {
|
|||||||
t.Errorf("got %v", m)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user