panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
package regionmedic
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// BackupRef identifies one panel backup tarball for an instance.
|
||||
type BackupRef struct {
|
||||
Path string `json:"path"`
|
||||
ID string `json:"id,omitempty"` // "bkp_<hex>" parsed from the name
|
||||
CreatedAt time.Time `json:"created_at"` // parsed from the filename (UTC)
|
||||
}
|
||||
|
||||
// backupTimeLayout matches the panel backup filename timestamp
|
||||
// (time.Now().UTC().Format) — see agent/internal/dispatch/backup.go.
|
||||
const backupTimeLayout = "20060102-150405"
|
||||
|
||||
// ListPanelBackups reads an instance's backup directory
|
||||
// (<agent --backup-dir>/<instance_id>/) and returns the backups sorted newest
|
||||
// first, parsing the UTC timestamp + bkp id out of each filename:
|
||||
//
|
||||
// 20060102-150405-bkp_<hex>.tar.gz
|
||||
//
|
||||
// Files that don't match the pattern are skipped.
|
||||
func ListPanelBackups(instanceBackupDir string) ([]BackupRef, error) {
|
||||
ents, err := os.ReadDir(instanceBackupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var refs []BackupRef
|
||||
for _, e := range ents {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasSuffix(name, ".tar.gz") {
|
||||
continue
|
||||
}
|
||||
ref, ok := parseBackupName(name)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ref.Path = filepath.Join(instanceBackupDir, name)
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
sort.Slice(refs, func(i, j int) bool { return refs[i].CreatedAt.After(refs[j].CreatedAt) })
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// parseBackupName parses "20060102-150405-bkp_<hex>.tar.gz".
|
||||
func parseBackupName(name string) (BackupRef, bool) {
|
||||
base := strings.TrimSuffix(name, ".tar.gz")
|
||||
// timestamp is the first 15 chars: YYYYMMDD-HHMMSS
|
||||
if len(base) < len(backupTimeLayout) {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
tsPart := base[:len(backupTimeLayout)]
|
||||
ts, err := time.Parse(backupTimeLayout, tsPart)
|
||||
if err != nil {
|
||||
return BackupRef{}, false
|
||||
}
|
||||
ref := BackupRef{CreatedAt: ts.UTC()}
|
||||
if i := strings.Index(base, "bkp_"); i >= 0 {
|
||||
ref.ID = base[i:]
|
||||
}
|
||||
return ref, true
|
||||
}
|
||||
|
||||
// ExtractRegionFromBackup pulls one region file out of a panel backup tarball.
|
||||
// The tar stores paths relative to the backup root, and a single backup may
|
||||
// contain SEVERAL worlds (e.g. ".../Saves/Navezgane/MyGame/Region/r.0.0.7rg"
|
||||
// AND ".../Saves/West Apeeni Mountains/MapRender/Region/r.0.0.7rg"). The caller
|
||||
// MUST therefore pass a world-qualified entry suffix — e.g.
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg" — so the correct world's region is
|
||||
// returned; a bare "r.0.0.7rg" would be ambiguous and could heal from the wrong
|
||||
// world. An entry matches when its cleaned path equals entry or ends with
|
||||
// "/"+entry. Returns ErrRegionNotInBackup if no entry matches.
|
||||
func ExtractRegionFromBackup(backupPath, entry string) ([]byte, error) {
|
||||
f, err := os.Open(backupPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
want := strings.TrimPrefix(entry, "/")
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
continue
|
||||
}
|
||||
clean := strings.TrimPrefix(path.Clean(hdr.Name), "./")
|
||||
if clean == want || strings.HasSuffix(clean, "/"+want) {
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read entry: %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%w: %s", ErrRegionNotInBackup, entry)
|
||||
}
|
||||
|
||||
// SaveRelEntry converts an absolute Region directory and a region filename into
|
||||
// the world-qualified backup entry suffix used by ExtractRegionFromBackup. A
|
||||
// regionDir of ".../Saves/Navezgane/MyGame/Region" + "r.0.0.7rg" yields
|
||||
// "Saves/Navezgane/MyGame/Region/r.0.0.7rg". If regionDir contains no "/Saves/"
|
||||
// segment it falls back to the (ambiguous) "Region/<name>".
|
||||
func SaveRelEntry(regionDir, regionFileName string) string {
|
||||
slashed := filepath.ToSlash(regionDir)
|
||||
if i := strings.Index(slashed, "/Saves/"); i >= 0 {
|
||||
return slashed[i+1:] + "/" + regionFileName // from "Saves/" onward
|
||||
}
|
||||
if strings.HasPrefix(slashed, "Saves/") {
|
||||
return slashed + "/" + regionFileName
|
||||
}
|
||||
return "Region/" + regionFileName
|
||||
}
|
||||
|
||||
// ErrRegionNotInBackup is returned when a backup doesn't contain the region.
|
||||
var ErrRegionNotInBackup = fmt.Errorf("region not found in backup")
|
||||
|
||||
// Candidate is a backup evaluated as a heal source for one region.
|
||||
type Candidate struct {
|
||||
Backup BackupRef `json:"backup"`
|
||||
Found bool `json:"found"` // region present in this backup
|
||||
Report RegionReport `json:"report"` // validation of the extracted region
|
||||
Bytes []byte `json:"-"` // extracted region bytes (best only)
|
||||
Note string `json:"note,omitempty"` // why skipped, if applicable
|
||||
}
|
||||
|
||||
// Clean reports whether this candidate's region image validated healthy.
|
||||
func (c Candidate) Clean() bool { return c.Found && c.Report.Healthy() }
|
||||
|
||||
// FindLastGoodRegion walks backups newest→oldest, extracts the region (using a
|
||||
// world-qualified entry suffix — see SaveRelEntry) from each and validates it,
|
||||
// and returns the newest CLEAN candidate (best.Found && best.Clean()). The full
|
||||
// evaluated list is returned for logging/auditing. Only the chosen best carries
|
||||
// Bytes (others are dropped to save memory). If no clean copy is found,
|
||||
// best.Found is false.
|
||||
//
|
||||
// backups should already be sorted newest-first (ListPanelBackups does this);
|
||||
// FindLastGoodRegion re-sorts defensively. An optional notAfter bound skips
|
||||
// backups created at/after it (use the corruption time to avoid trusting a
|
||||
// snapshot that already captured the damage); pass the zero time to disable.
|
||||
func FindLastGoodRegion(backups []BackupRef, entry string, deep bool, notAfter time.Time) (best Candidate, evaluated []Candidate, err error) {
|
||||
sorted := append([]BackupRef(nil), backups...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].CreatedAt.After(sorted[j].CreatedAt) })
|
||||
|
||||
for _, b := range sorted {
|
||||
c := Candidate{Backup: b}
|
||||
if !notAfter.IsZero() && !b.CreatedAt.Before(notAfter) {
|
||||
c.Note = "skipped: at/after corruption time"
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
data, exErr := ExtractRegionFromBackup(b.Path, entry)
|
||||
if exErr != nil {
|
||||
c.Note = exErr.Error()
|
||||
evaluated = append(evaluated, c)
|
||||
continue
|
||||
}
|
||||
c.Found = true
|
||||
c.Report = ValidateRegionBytes(data, deep)
|
||||
if c.Report.Healthy() && !best.Found {
|
||||
c.Bytes = data
|
||||
best = c
|
||||
}
|
||||
evaluated = append(evaluated, c)
|
||||
}
|
||||
return best, evaluated, nil
|
||||
}
|
||||
Reference in New Issue
Block a user