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 <noreply@anthropic.com>
This commit is contained in:
gregandClaude Opus 5.5 committed 2026-10-26 00:27:57 -04:00
1 parent 87dfca1285
commit 0a80a6ebc5
4 files changed
+69 -41

No files matched your search

+2
View File
@@ -30,7 +30,9 @@ func TestOutsideDirsSkipsSteamNoise(t *testing.T) {
r := &bootReport{ContainerChanges: []string{ r := &bootReport{ContainerChanges: []string{
"A /home/steam/.config/example-game/worlds/w.db", "A /home/steam/.config/example-game/worlds/w.db",
"A /home/steam/Steam/logs/connection_log.txt", "A /home/steam/Steam/logs/connection_log.txt",
"C /home/steam/Steam/config/config.vdf",
"C /home/steam/.steam/registry.vdf", "C /home/steam/.steam/registry.vdf",
"A /usr/sbin/docker-init",
}} }}
got := outsideDirs(r) got := outsideDirs(r)
if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" { if len(got) != 1 || got[0] != "/home/steam/.config/example-game/worlds" {
+2 -11
View File
@@ -336,9 +336,6 @@ func closestVar(name string, vars []string) string {
return best 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 // 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 // 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 // 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{} dirs := map[string]bool{}
for _, c := range report.ContainerChanges { for _, c := range report.ContainerChanges {
_, p, _ := strings.Cut(c, " ") // A report saved by an older version can still list files that aren't the game's state
noise := false if _, p, _ := strings.Cut(c, " "); p != "" && !notGameState(p) {
for _, n := range outsideNoise {
if strings.Contains(p, n) {
noise = true
}
}
if !noise && p != "" {
dirs[path.Dir(p)] = true dirs[path.Dir(p)] = true
} }
} }
+49 -29
View File
@@ -512,16 +512,36 @@ func (b *booter) fileLines(report *bootReport) []logLine {
return out 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 ( 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} 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 // 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 // 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 // the image's VOLUME folders, which Docker gives fresh anonymous volumes each time. Files that aren't
// files are left out; saves of any kind are not. // the game's state are left out; saves of any kind are not.
func (b *booter) containerChanges() []string { func (b *booter) containerChanges() []string {
var changes []string var changes []string
if out, err := exec.Command("docker", "diff", b.container).Output(); err == nil { if out, err := exec.Command("docker", "diff", b.container).Output(); err == nil {
@@ -541,7 +561,7 @@ func (b *booter) containerChanges() []string {
n := 0 n := 0
for _, f := range strings.Split(strings.TrimSpace(string(files)), "\n") { for _, f := range strings.Split(strings.TrimSpace(string(files)), "\n") {
rel := strings.TrimPrefix(f, "/v/") rel := strings.TrimPrefix(f, "/v/")
if f == "" || transientExt[strings.ToLower(path.Ext(rel))] { if f == "" || notGameState(path.Join(dest, rel)) {
continue continue
} }
if n++; n > 200 { if n++; n > 200 {
@@ -553,42 +573,42 @@ func (b *booter) containerChanges() []string {
return changes return changes
} }
// outsideFiles picks the files out of `docker diff` output: added or changed leaves (docker diff lists // outsideFiles picks the game's files out of `docker diff` output: added or changed outside the mounted
// folders too) outside the mounted volumes and system folders, without transient files. // 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 { func outsideFiles(diff string, mounts map[string]string) []string {
var paths []string
kinds := map[string]string{} kinds := map[string]string{}
parents := map[string]bool{}
for _, line := range strings.Split(strings.TrimSpace(diff), "\n") { for _, line := range strings.Split(strings.TrimSpace(diff), "\n") {
kind, p, ok := strings.Cut(strings.TrimSpace(line), " ") kind, p, ok := strings.Cut(strings.TrimSpace(line), " ")
if !ok || kind == "D" || p == "" { if !ok || p == "" {
continue continue
} }
skip := transientExt[strings.ToLower(path.Ext(p))] kinds[p] = kind
for _, prefix := range systemPrefixes { for d := path.Dir(p); d != "/" && d != "."; d = path.Dir(d) {
if p == prefix || strings.HasPrefix(p, prefix+"/") { parents[d] = true
skip = true
} }
} }
var files []string
for p, kind := range kinds {
if kind != "D" && !parents[p] && !notGameState(p) && !inMounts(p, mounts) {
files = append(files, p)
}
}
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 { for mount := range mounts {
if p == mount || strings.HasPrefix(p, mount+"/") { if p == mount || strings.HasPrefix(p, mount+"/") {
skip = true return true
} }
} }
if !skip { return false
paths = append(paths, p)
kinds[p] = kind
}
}
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
}
leaves = append(leaves, kinds[p]+" "+p)
}
return leaves
} }
func (b *booter) cleanup() { func (b *booter) cleanup() {
+16 -1
View File
@@ -113,15 +113,30 @@ A /home/steam/.config/example
A /home/steam/.config/example/worlds A /home/steam/.config/example/worlds
A /home/steam/.config/example/worlds/world1.db A /home/steam/.config/example/worlds/world1.db
A /home/steam/.config/example/worlds/world1.fwl A /home/steam/.config/example/worlds/world1.fwl
A /home/steam/.config/example/worlds.bak
A /home/steam/.config/example/Player.log 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
C /etc/hostname 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 /tmp/x.sock
A /server/should-not-count.sav A /server/should-not-count.sav
D /home/steam/old.txt D /home/steam/old.txt
A /home/steam/server.pid` A /home/steam/server.pid`
got := outsideFiles(diff, map[string]string{"/server": "v", "/data": "v"}) 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, "|") { if strings.Join(got, "|") != strings.Join(want, "|") {
t.Errorf("got %q\nwant %q", got, want) t.Errorf("got %q\nwant %q", got, want)
} }