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:42:34 -04:00
1 parent d225e49a8a
commit 709cdb51e8
4 files changed
+71 -43

No files matched your search

+51 -31
View File
@@ -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() {