From 0a80a6ebc5783e03b596ca5dbfed62b367b7850b Mon Sep 17 00:00:00 2001 From: Greg Date: Mon, 26 Oct 2026 00:27:57 -0400 Subject: [PATCH] fix(builder): don't count Docker's init or the Steam client's own files as lost saves Every container the node-agent starts gets /usr/sbin/docker-init, and SteamCMD rewrites its own config.vdf on every run. Counted as files written outside the volumes, they made every build look like it loses saves. A folder whose only changes were logs no longer counts either, and a folder followed by a same-named file (worlds, worlds.bak) is no longer taken for a file. Co-Authored-By: Claude Opus 5.5 --- cmd/gbrgames/build_test.go | 2 + cmd/gbrgames/draft.go | 13 +----- cmd/gbrgames/firstboot.go | 82 +++++++++++++++++++++------------- cmd/gbrgames/firstboot_test.go | 17 ++++++- 4 files changed, 71 insertions(+), 43 deletions(-) diff --git a/cmd/gbrgames/build_test.go b/cmd/gbrgames/build_test.go index 7f6a0c3..24b1c9e 100644 --- a/cmd/gbrgames/build_test.go +++ b/cmd/gbrgames/build_test.go @@ -30,7 +30,9 @@ func TestOutsideDirsSkipsSteamNoise(t *testing.T) { r := &bootReport{ContainerChanges: []string{ "A /home/steam/.config/example-game/worlds/w.db", "A /home/steam/Steam/logs/connection_log.txt", + "C /home/steam/Steam/config/config.vdf", "C /home/steam/.steam/registry.vdf", + "A /usr/sbin/docker-init", }} got := outsideDirs(r) if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" { diff --git a/cmd/gbrgames/draft.go b/cmd/gbrgames/draft.go index d8d4ba3..f69a08f 100644 --- a/cmd/gbrgames/draft.go +++ b/cmd/gbrgames/draft.go @@ -336,9 +336,6 @@ func closestVar(name string, vars []string) string { return best } -// Files the Steam client, Proton and the OS write for themselves: not the game's data -var outsideNoise = []string{"/Steam/logs/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"} - // savesOutsideVolumes turns first boot's "written outside the volumes" into a hard check: anything the // game itself wrote there (worlds or player lists under the user's home folder) is lost every time the // node recreates the container. A template passes once its startup command or settings point the game at @@ -372,14 +369,8 @@ func outsideDirs(report *bootReport) []string { } dirs := map[string]bool{} for _, c := range report.ContainerChanges { - _, p, _ := strings.Cut(c, " ") - noise := false - for _, n := range outsideNoise { - if strings.Contains(p, n) { - noise = true - } - } - if !noise && p != "" { + // A report saved by an older version can still list files that aren't the game's state + if _, p, _ := strings.Cut(c, " "); p != "" && !notGameState(p) { dirs[path.Dir(p)] = true } } diff --git a/cmd/gbrgames/firstboot.go b/cmd/gbrgames/firstboot.go index cc80955..dbe2200 100644 --- a/cmd/gbrgames/firstboot.go +++ b/cmd/gbrgames/firstboot.go @@ -512,16 +512,36 @@ func (b *booter) fileLines(report *bootReport) []logLine { return out } -// System folders and files that aren't the game's state: what's written there doesn't matter when lost +// Files that aren't the game's state, so losing them doesn't matter: system folders, Docker's own init +// (in every container started with --init), logs, lock and pid files, and what the Steam client, Proton +// and the OS keep for themselves in the home folder var ( - systemPrefixes = []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/etc", "/var/log", "/var/cache", "/var/tmp", "/var/lib/apt"} + systemPrefixes = []string{"/tmp", "/proc", "/sys", "/dev", "/run", "/etc", "/var/log", "/var/cache", "/var/tmp", "/var/lib/apt", "/usr/sbin/docker-init"} transientExt = map[string]bool{".log": true, ".pid": true, ".lock": true, ".tmp": true, ".swp": true} + clientNoise = []string{"/Steam/logs/", "/Steam/config/", "/Steam/appcache/", "/.steam/", "/steamcmd/", "/.cache/", "/tmp/", "/.local/share/umu"} ) +func notGameState(p string) bool { + if transientExt[strings.ToLower(path.Ext(p))] { + return true + } + for _, prefix := range systemPrefixes { + if p == prefix || strings.HasPrefix(p, prefix+"/") { + return true + } + } + for _, n := range clientNoise { + if strings.Contains(p, n) { + return true + } + } + return false +} + // containerChanges lists the files the server wrote anywhere but /server and /data. All of them are // lost when the node recreates the container (every start): in the container's own filesystem, and in -// the image's VOLUME folders, which Docker gives fresh anonymous volumes each time. Logs, lock and pid -// files are left out; saves of any kind are not. +// the image's VOLUME folders, which Docker gives fresh anonymous volumes each time. Files that aren't +// the game's state are left out; saves of any kind are not. func (b *booter) containerChanges() []string { var changes []string if out, err := exec.Command("docker", "diff", b.container).Output(); err == nil { @@ -541,7 +561,7 @@ func (b *booter) containerChanges() []string { n := 0 for _, f := range strings.Split(strings.TrimSpace(string(files)), "\n") { rel := strings.TrimPrefix(f, "/v/") - if f == "" || transientExt[strings.ToLower(path.Ext(rel))] { + if f == "" || notGameState(path.Join(dest, rel)) { continue } if n++; n > 200 { @@ -553,42 +573,42 @@ func (b *booter) containerChanges() []string { return changes } -// outsideFiles picks the files out of `docker diff` output: added or changed leaves (docker diff lists -// folders too) outside the mounted volumes and system folders, without transient files. +// outsideFiles picks the game's files out of `docker diff` output: added or changed outside the mounted +// volumes. docker diff lists folders too, as changed when anything inside them changed, so a folder only +// counts when nothing inside it did (it was created empty). func outsideFiles(diff string, mounts map[string]string) []string { - var paths []string kinds := map[string]string{} + parents := map[string]bool{} for _, line := range strings.Split(strings.TrimSpace(diff), "\n") { kind, p, ok := strings.Cut(strings.TrimSpace(line), " ") - if !ok || kind == "D" || p == "" { + if !ok || p == "" { continue } - skip := transientExt[strings.ToLower(path.Ext(p))] - for _, prefix := range systemPrefixes { - if p == prefix || strings.HasPrefix(p, prefix+"/") { - skip = true - } - } - for mount := range mounts { - if p == mount || strings.HasPrefix(p, mount+"/") { - skip = true - } - } - if !skip { - paths = append(paths, p) - kinds[p] = kind + kinds[p] = kind + for d := path.Dir(p); d != "/" && d != "."; d = path.Dir(d) { + parents[d] = true } } - sort.Strings(paths) - var leaves []string - for i, p := range paths { - // A folder is followed (sorted) by the entries inside it - if i+1 < len(paths) && strings.HasPrefix(paths[i+1], p+"/") { - continue + var files []string + for p, kind := range kinds { + if kind != "D" && !parents[p] && !notGameState(p) && !inMounts(p, mounts) { + files = append(files, p) } - leaves = append(leaves, kinds[p]+" "+p) } - return leaves + sort.Strings(files) + for i, p := range files { + files[i] = kinds[p] + " " + p + } + return files +} + +func inMounts(p string, mounts map[string]string) bool { + for mount := range mounts { + if p == mount || strings.HasPrefix(p, mount+"/") { + return true + } + } + return false } func (b *booter) cleanup() { diff --git a/cmd/gbrgames/firstboot_test.go b/cmd/gbrgames/firstboot_test.go index 4a6530e..f8217d9 100644 --- a/cmd/gbrgames/firstboot_test.go +++ b/cmd/gbrgames/firstboot_test.go @@ -113,15 +113,30 @@ A /home/steam/.config/example A /home/steam/.config/example/worlds A /home/steam/.config/example/worlds/world1.db A /home/steam/.config/example/worlds/world1.fwl +A /home/steam/.config/example/worlds.bak A /home/steam/.config/example/Player.log +C /home/steam/logs +A /home/steam/logs/server.log +C /home/steam/Steam +C /home/steam/Steam/config +C /home/steam/Steam/config/config.vdf +C /home/steam/steamcmd/linux64/steamclient.so C /etc C /etc/hostname +C /usr +C /usr/sbin +A /usr/sbin/docker-init +C /var +C /var/log +A /var/log/x A /tmp/x.sock A /server/should-not-count.sav D /home/steam/old.txt A /home/steam/server.pid` got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"}) - want := []string{"A /home/steam/.config/example/worlds/world1.db", "A /home/steam/.config/example/worlds/world1.fwl"} + // Folders whose only changes aren't the game's (logs, Docker's init) don't count either + want := []string{"A /home/steam/.config/example/worlds.bak", "A /home/steam/.config/example/worlds/world1.db", + "A /home/steam/.config/example/worlds/world1.fwl"} if strings.Join(got, "|") != strings.Join(want, "|") { t.Errorf("got %q\nwant %q", got, want) }