CI deployed every Steam game from scratch on each run, downloading the whole game from Steam each time. scripts/boot-test.sh now runs the new `gbrgames boot-test ID`, the builder's own deploy path: the same payload and node-agent BootTest, but Steam games start from the cache volume (gbr-steamcache-<app>, kept by RUNNER.md's prune). The cache is updated at most once a day (a timestamp in the cache volume, not copied into servers), so a repeat run within a day doesn't ask Steam for an update at all; the node-agent's own install still checks the files and fetches anything newer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.6 KiB
Go
169 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Every server the builder starts, first boots included, is deployed by the node-agent's own code
|
|
// (BootTest), the same code that runs servers on nodes and in CI, so what the builder sees is what a
|
|
// node does. This file runs it and keeps the Steam download cache that makes repeated boots cheap.
|
|
|
|
func agentImageFromEnv() string {
|
|
if img := os.Getenv("AGENT_IMAGE"); img != "" {
|
|
return img
|
|
}
|
|
return defaultAgentImage
|
|
}
|
|
|
|
// pullAgentImage fetches the latest node-agent image. A copy that is only local (a test build) is used as
|
|
// it is.
|
|
func pullAgentImage(img string) error {
|
|
out, err := runCmd("docker", "pull", "-q", img)
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if _, inspectErr := runCmd("docker", "image", "inspect", img); inspectErr == nil {
|
|
fmt.Printf("[agent] could not pull %s (%s); using the local copy\n", img, firstLine(out))
|
|
return nil
|
|
}
|
|
return fmt.Errorf("docker pull %s: %v: %s", img, err, out)
|
|
}
|
|
|
|
// runAgentBootTest deploys a payload with BootTest and returns its output; passed is its verdict.
|
|
func runAgentBootTest(payload map[string]any, agentImage string) (output string, passed bool, err error) {
|
|
container := fmt.Sprint(payload["containerName"])
|
|
// A run that was killed (worker restarted mid-build) leaves its container and volume behind, and the
|
|
// next deploy under the same name would trip over them
|
|
runCmd("docker", "rm", "-f", "-v", container)
|
|
runCmd("docker", "volume", "rm", "-f", container+"-data")
|
|
if payload["installMethod"] == "STEAMCMD" {
|
|
if ic, ok := payload["installConfig"].(map[string]any); ok && ic["appId"] != nil {
|
|
seedSteamApp(fmt.Sprint(ic["appId"]), container+"-data")
|
|
}
|
|
}
|
|
raw, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
cmd := exec.Command("docker", "run", "--rm", "-i", "-v", "/var/run/docker.sock:/var/run/docker.sock",
|
|
"--entrypoint", "java", agentImage, "-Dloader.main=com.gbreed.gbr.nodeagent.BootTest", "-cp", "app.jar",
|
|
"org.springframework.boot.loader.launch.PropertiesLauncher")
|
|
cmd.Stdin = bytes.NewReader(raw)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &teeWriter{&out}
|
|
cmd.Stderr = cmd.Stdout
|
|
if runErr := cmd.Run(); runErr != nil {
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(runErr, &exitErr) {
|
|
return "", false, runErr
|
|
}
|
|
return out.String(), false, nil
|
|
}
|
|
return out.String(), true, nil
|
|
}
|
|
|
|
// Steam apps whose cached install is up to date in this run (one build or first boot)
|
|
var steamCacheFresh = map[string]bool{}
|
|
|
|
var numericAppID = regexp.MustCompile(`^\d+$`)
|
|
|
|
// When the cache was last updated, in the cache volume (not copied into servers)
|
|
const steamCacheStamp = ".gbr-cache-updated"
|
|
|
|
// steamCacheMaxAge: a cache updated more recently isn't updated again, which saves Steam a login and an
|
|
// update check per boot; the node-agent's own install still fetches anything newer
|
|
const steamCacheMaxAge = 24 * time.Hour
|
|
|
|
// seedSteamApp fills a server volume from a cached SteamCMD install of the app, so a boot doesn't
|
|
// download the whole game again: the node-agent's own install then only checks the files. The cache is
|
|
// updated at most once a day. Without it (STEAM_CACHE=off, or the update failing) the agent downloads as usual.
|
|
func seedSteamApp(appID, volume string) {
|
|
if os.Getenv("STEAM_CACHE") == "off" || !numericAppID.MatchString(appID) {
|
|
return
|
|
}
|
|
cache := "gbr-steamcache-" + appID
|
|
if !steamCacheFresh[appID] && steamCacheUpdatedWithin(cache, steamCacheMaxAge) {
|
|
fmt.Printf("[steam-cache] the cached install of Steam app %s was updated in the last %s\n", appID, steamCacheMaxAge)
|
|
steamCacheFresh[appID] = true
|
|
}
|
|
if !steamCacheFresh[appID] {
|
|
fmt.Printf("[steam-cache] updating the cached install of Steam app %s\n", appID)
|
|
if out, err := runCmd("docker", "volume", "create", "--label", "gbr.cache=steam", cache); err != nil {
|
|
fmt.Printf("[steam-cache] %v: %s\n", err, out)
|
|
return
|
|
}
|
|
runCmd("docker", "run", "--rm", "-v", cache+":/server", "alpine", "chown", "-R", nodeUser, "/server")
|
|
// SteamCMD's first install on a fresh client sometimes fails ("Missing configuration") until it has
|
|
// logged in once; retried a few times
|
|
script := "s=/home/steam/steamcmd/steamcmd.sh; $s +login anonymous +quit >/dev/null; for i in 1 2 3; do " +
|
|
"$s +force_install_dir /server +login anonymous +app_update " + appID + " +quit && exit 0; sleep 15; done; exit 1"
|
|
cmd := exec.Command("docker", "run", "--rm", "--network", bootTestNetwork, "-v", cache+":/server", "cm2network/steamcmd", "bash", "-c", script)
|
|
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
|
|
if err := cmd.Run(); err != nil {
|
|
fmt.Printf("[steam-cache] update failed (%v); the boot downloads the app itself\n", err)
|
|
return
|
|
}
|
|
runCmd("docker", "run", "--rm", "-v", cache+":/server", "alpine", "sh", "-c",
|
|
fmt.Sprintf("date +%%s > /server/%s", steamCacheStamp))
|
|
steamCacheFresh[appID] = true
|
|
}
|
|
runCmd("docker", "volume", "create", volume)
|
|
if out, err := runCmd("docker", "run", "--rm", "-v", cache+":/from:ro", "-v", volume+":/to", "alpine", "sh", "-c",
|
|
"cp -a /from/. /to/ && rm -f /to/"+steamCacheStamp); err != nil {
|
|
fmt.Printf("[steam-cache] copying into %s failed (%v: %s); the boot downloads the app itself\n", volume, err, out)
|
|
return
|
|
}
|
|
fmt.Printf("[steam-cache] %s seeded from the cached install of app %s\n", volume, appID)
|
|
}
|
|
|
|
func steamCacheUpdatedWithin(cache string, age time.Duration) bool {
|
|
out, err := runCmd("docker", "run", "--rm", "-v", cache+":/c:ro", "alpine", "cat", "/c/"+steamCacheStamp)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return stampWithin(out, time.Now(), age)
|
|
}
|
|
|
|
func stampWithin(stamp string, now time.Time, age time.Duration) bool {
|
|
secs, err := strconv.ParseInt(strings.TrimSpace(stamp), 10, 64)
|
|
return err == nil && now.Sub(time.Unix(secs, 0)) < age
|
|
}
|
|
|
|
// runBootTest boot-tests one game the way CI does: its real payload, deployed by the node-agent, with
|
|
// the Steam download cache. Exits 3 when a secret it needs isn't set, 1 when it fails.
|
|
func runBootTest(args []string) error {
|
|
fs := flag.NewFlagSet("boot-test", flag.ExitOnError)
|
|
gamesDir := fs.String("games", "games", "directory holding one folder per game")
|
|
fs.Parse(args)
|
|
if fs.NArg() != 1 {
|
|
return errors.New("boot-test needs a game id")
|
|
}
|
|
payload, err := bootTestPayload(*gamesDir, fs.Arg(0), false)
|
|
var missing *missingSecretError
|
|
if errors.As(err, &missing) {
|
|
fmt.Fprintln(os.Stderr, "skip: "+missing.Error())
|
|
os.Exit(exitMissingSecret)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, passed, err := runAgentBootTest(payload, agentImageFromEnv())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !passed {
|
|
os.Exit(1)
|
|
}
|
|
return nil
|
|
}
|