backup updates 4-16-26
This commit is contained in:
parent
1e9facacff
commit
94bcdf2e78
@ -22,11 +22,18 @@ const (
|
|||||||
RootDir = "/opt/zlh-agent/backups"
|
RootDir = "/opt/zlh-agent/backups"
|
||||||
defaultMaxCount = 10
|
defaultMaxCount = 10
|
||||||
manifestName = "backup_manifest.json"
|
manifestName = "backup_manifest.json"
|
||||||
|
|
||||||
|
BackupTypeManual = "manual"
|
||||||
|
BackupTypeCheckpoint = "checkpoint"
|
||||||
|
|
||||||
|
BackupReasonPreRestore = "pre_restore"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Manifest struct {
|
type Manifest struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAtUTC string `json:"created_at_utc"`
|
CreatedAtUTC string `json:"created_at_utc"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
ContainerType string `json:"container_type"`
|
ContainerType string `json:"container_type"`
|
||||||
Game string `json:"game"`
|
Game string `json:"game"`
|
||||||
Variant string `json:"variant"`
|
Variant string `json:"variant"`
|
||||||
@ -38,31 +45,75 @@ type Manifest struct {
|
|||||||
TotalBytes int64 `json:"total_bytes"`
|
TotalBytes int64 `json:"total_bytes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RestoreResult struct {
|
||||||
|
Backup Manifest `json:"backup"`
|
||||||
|
Checkpoint Manifest `json:"checkpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createOptions struct {
|
||||||
|
backupType string
|
||||||
|
reason string
|
||||||
|
prune bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootDir = RootDir
|
||||||
|
serverDir = provision.ServerDir
|
||||||
|
getServerPID = system.GetServerPID
|
||||||
|
runSaveOff = system.RunMinecraftSaveOff
|
||||||
|
runSaveOn = system.RunMinecraftSaveOn
|
||||||
|
stopServerAndWait = system.StopServerAndWait
|
||||||
|
startServerReady = system.StartServerReady
|
||||||
|
)
|
||||||
|
|
||||||
func Create(cfg *state.Config) (Manifest, error) {
|
func Create(cfg *state.Config) (Manifest, error) {
|
||||||
|
return create(cfg, createOptions{
|
||||||
|
backupType: BackupTypeManual,
|
||||||
|
prune: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCheckpoint(cfg *state.Config) (Manifest, error) {
|
||||||
|
return create(cfg, createOptions{
|
||||||
|
backupType: BackupTypeCheckpoint,
|
||||||
|
reason: BackupReasonPreRestore,
|
||||||
|
prune: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func create(cfg *state.Config, opts createOptions) (Manifest, error) {
|
||||||
if err := requireMinecraft(cfg); err != nil {
|
if err := requireMinecraft(cfg); err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
if err := os.MkdirAll(RootDir, 0o755); err != nil {
|
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
id := time.Now().UTC().Format("20060102T150405Z")
|
id, err := nextBackupID(time.Now().UTC())
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
archiveName := id + ".tar.gz"
|
archiveName := id + ".tar.gz"
|
||||||
archivePath := filepath.Join(RootDir, archiveName)
|
archivePath := filepath.Join(rootDir, archiveName)
|
||||||
serverRoot := provision.ServerDir(*cfg)
|
serverRoot := serverDir(*cfg)
|
||||||
paths := defaultPaths(cfg, serverRoot)
|
paths := defaultPaths(cfg, serverRoot)
|
||||||
|
|
||||||
_, running := system.GetServerPID()
|
backupType := strings.TrimSpace(opts.backupType)
|
||||||
|
if backupType == "" {
|
||||||
|
backupType = BackupTypeManual
|
||||||
|
}
|
||||||
|
|
||||||
|
_, running := getServerPID()
|
||||||
saveOff := false
|
saveOff := false
|
||||||
if running {
|
if running {
|
||||||
state.SetOperationMessage("flushing minecraft saves")
|
state.SetOperationMessage("flushing minecraft saves")
|
||||||
if err := system.RunMinecraftSaveOff(); err != nil {
|
if err := runSaveOff(); err != nil {
|
||||||
return Manifest{}, fmt.Errorf("disable minecraft saves: %w", err)
|
return Manifest{}, fmt.Errorf("disable minecraft saves: %w", err)
|
||||||
}
|
}
|
||||||
saveOff = true
|
saveOff = true
|
||||||
defer func() {
|
defer func() {
|
||||||
if saveOff {
|
if saveOff {
|
||||||
_ = system.RunMinecraftSaveOn()
|
_ = runSaveOn()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@ -71,6 +122,8 @@ func Create(cfg *state.Config) (Manifest, error) {
|
|||||||
manifest := Manifest{
|
manifest := Manifest{
|
||||||
ID: id,
|
ID: id,
|
||||||
CreatedAtUTC: time.Now().UTC().Format(time.RFC3339),
|
CreatedAtUTC: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
Type: backupType,
|
||||||
|
Reason: strings.TrimSpace(opts.reason),
|
||||||
ContainerType: cfg.ContainerType,
|
ContainerType: cfg.ContainerType,
|
||||||
Game: cfg.Game,
|
Game: cfg.Game,
|
||||||
Variant: cfg.Variant,
|
Variant: cfg.Variant,
|
||||||
@ -83,7 +136,7 @@ func Create(cfg *state.Config) (Manifest, error) {
|
|||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
if saveOff {
|
if saveOff {
|
||||||
if err := system.RunMinecraftSaveOn(); err != nil {
|
if err := runSaveOn(); err != nil {
|
||||||
return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err)
|
return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err)
|
||||||
}
|
}
|
||||||
saveOff = false
|
saveOff = false
|
||||||
@ -91,14 +144,61 @@ func Create(cfg *state.Config) (Manifest, error) {
|
|||||||
if err := writeManifestSidecar(manifest); err != nil {
|
if err := writeManifestSidecar(manifest); err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
|
if opts.prune {
|
||||||
if err := prune(defaultMaxCount); err != nil {
|
if err := prune(defaultMaxCount); err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Restore(cfg *state.Config, id string) (RestoreResult, error) {
|
||||||
|
if err := requireMinecraft(cfg); err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if !safeID(id) {
|
||||||
|
return RestoreResult{}, fmt.Errorf("invalid backup id")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := readManifestSidecar(id)
|
||||||
|
if err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
archivePath := filepath.Join(rootDir, manifest.Archive)
|
||||||
|
if _, err := os.Stat(archivePath); err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SetOperationMessage("creating pre-restore checkpoint")
|
||||||
|
checkpoint, err := createCheckpoint(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return RestoreResult{}, fmt.Errorf("create pre-restore checkpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, running := getServerPID(); running {
|
||||||
|
state.SetOperationMessage("stopping server before restore")
|
||||||
|
if err := stopServerAndWait(30 * time.Second); err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SetOperationMessage("restoring backup archive")
|
||||||
|
if err := restoreArchive(serverDir(*cfg), archivePath, manifest.Paths); err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
state.SetOperationMessage("starting server after restore")
|
||||||
|
state.SetState(state.StateStarting)
|
||||||
|
state.SetReadyState(false, "", "")
|
||||||
|
if err := startServerReady(cfg); err != nil {
|
||||||
|
return RestoreResult{}, err
|
||||||
|
}
|
||||||
|
return RestoreResult{Backup: manifest, Checkpoint: checkpoint}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func List() ([]Manifest, error) {
|
func List() ([]Manifest, error) {
|
||||||
entries, err := os.ReadDir(RootDir)
|
entries, err := os.ReadDir(rootDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return []Manifest{}, nil
|
return []Manifest{}, nil
|
||||||
@ -132,8 +232,8 @@ func Delete(id string) error {
|
|||||||
archiveName = filepath.Base(manifest.Archive)
|
archiveName = filepath.Base(manifest.Archive)
|
||||||
}
|
}
|
||||||
|
|
||||||
archivePath := filepath.Join(RootDir, archiveName)
|
archivePath := filepath.Join(rootDir, archiveName)
|
||||||
manifestPath := filepath.Join(RootDir, id+".json")
|
manifestPath := filepath.Join(rootDir, id+".json")
|
||||||
|
|
||||||
archiveErr := os.Remove(archivePath)
|
archiveErr := os.Remove(archivePath)
|
||||||
if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) {
|
if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) {
|
||||||
@ -149,45 +249,6 @@ func Delete(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Restore(cfg *state.Config, id string) (Manifest, error) {
|
|
||||||
if err := requireMinecraft(cfg); err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
id = strings.TrimSpace(id)
|
|
||||||
if !safeID(id) {
|
|
||||||
return Manifest{}, fmt.Errorf("invalid backup id")
|
|
||||||
}
|
|
||||||
|
|
||||||
manifest, err := readManifestSidecar(id)
|
|
||||||
if err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
archivePath := filepath.Join(RootDir, manifest.Archive)
|
|
||||||
if _, err := os.Stat(archivePath); err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, running := system.GetServerPID(); running {
|
|
||||||
state.SetOperationMessage("stopping server before restore")
|
|
||||||
if err := system.StopServerAndWait(30 * time.Second); err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.SetOperationMessage("restoring backup archive")
|
|
||||||
if err := restoreArchive(provision.ServerDir(*cfg), archivePath, manifest.Paths); err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
state.SetOperationMessage("starting server after restore")
|
|
||||||
state.SetState(state.StateStarting)
|
|
||||||
state.SetReadyState(false, "", "")
|
|
||||||
if err := system.StartServerReady(cfg); err != nil {
|
|
||||||
return Manifest{}, err
|
|
||||||
}
|
|
||||||
return manifest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireMinecraft(cfg *state.Config) error {
|
func requireMinecraft(cfg *state.Config) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return fmt.Errorf("config required")
|
return fmt.Errorf("config required")
|
||||||
@ -394,14 +455,36 @@ func writeManifestSidecar(manifest Manifest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
data = append(data, '\n')
|
data = append(data, '\n')
|
||||||
return os.WriteFile(filepath.Join(RootDir, manifest.ID+".json"), data, 0o644)
|
return os.WriteFile(filepath.Join(rootDir, manifest.ID+".json"), data, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextBackupID(now time.Time) (string, error) {
|
||||||
|
base := now.UTC().Format("20060102T150405Z")
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
id := base
|
||||||
|
if i > 0 {
|
||||||
|
id = fmt.Sprintf("%s_%03d", base, i)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(rootDir, id+".json")); err == nil {
|
||||||
|
continue
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(rootDir, id+".tar.gz")); err == nil {
|
||||||
|
continue
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unable to allocate backup id")
|
||||||
}
|
}
|
||||||
|
|
||||||
func readManifestSidecar(id string) (Manifest, error) {
|
func readManifestSidecar(id string) (Manifest, error) {
|
||||||
if !safeID(id) {
|
if !safeID(id) {
|
||||||
return Manifest{}, fmt.Errorf("invalid backup id")
|
return Manifest{}, fmt.Errorf("invalid backup id")
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(filepath.Join(RootDir, id+".json"))
|
data, err := os.ReadFile(filepath.Join(rootDir, id+".json"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Manifest{}, err
|
return Manifest{}, err
|
||||||
}
|
}
|
||||||
@ -412,6 +495,9 @@ func readManifestSidecar(id string) (Manifest, error) {
|
|||||||
if manifest.ID != id {
|
if manifest.ID != id {
|
||||||
return Manifest{}, fmt.Errorf("backup manifest id mismatch")
|
return Manifest{}, fmt.Errorf("backup manifest id mismatch")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(manifest.Type) == "" {
|
||||||
|
manifest.Type = BackupTypeManual
|
||||||
|
}
|
||||||
return manifest, nil
|
return manifest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -424,8 +510,8 @@ func prune(maxCount int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := maxCount; i < len(backups); i++ {
|
for i := maxCount; i < len(backups); i++ {
|
||||||
_ = os.Remove(filepath.Join(RootDir, backups[i].Archive))
|
_ = os.Remove(filepath.Join(rootDir, backups[i].Archive))
|
||||||
_ = os.Remove(filepath.Join(RootDir, backups[i].ID+".json"))
|
_ = os.Remove(filepath.Join(rootDir, backups[i].ID+".json"))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
220
internal/backup/backup_test.go
Normal file
220
internal/backup/backup_test.go
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
package backup
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"zlh-agent/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestoreCreatesCheckpointBeforeDestructiveRestore(t *testing.T) {
|
||||||
|
cfg, serverRoot := setupBackupTest(t)
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "target")
|
||||||
|
|
||||||
|
target, err := Create(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create target backup: %v", err)
|
||||||
|
}
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "live")
|
||||||
|
|
||||||
|
started := false
|
||||||
|
startServerReady = func(*state.Config) error {
|
||||||
|
started = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Restore(cfg, target.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
if !started {
|
||||||
|
t.Fatalf("expected restore to restart server through readiness-aware path")
|
||||||
|
}
|
||||||
|
if result.Backup.ID != target.ID {
|
||||||
|
t.Fatalf("restored backup id = %q, want %q", result.Backup.ID, target.ID)
|
||||||
|
}
|
||||||
|
if result.Checkpoint.ID == "" || result.Checkpoint.ID == target.ID {
|
||||||
|
t.Fatalf("checkpoint id = %q, target id = %q", result.Checkpoint.ID, target.ID)
|
||||||
|
}
|
||||||
|
if result.Checkpoint.Type != BackupTypeCheckpoint {
|
||||||
|
t.Fatalf("checkpoint type = %q, want %q", result.Checkpoint.Type, BackupTypeCheckpoint)
|
||||||
|
}
|
||||||
|
if result.Checkpoint.Reason != BackupReasonPreRestore {
|
||||||
|
t.Fatalf("checkpoint reason = %q, want %q", result.Checkpoint.Reason, BackupReasonPreRestore)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(serverRoot, "world", "level.dat")); got != "target" {
|
||||||
|
t.Fatalf("restored file = %q, want target", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpointArchive := filepath.Join(rootDir, result.Checkpoint.Archive)
|
||||||
|
checkpointRoot := t.TempDir()
|
||||||
|
if err := restoreArchive(checkpointRoot, checkpointArchive, result.Checkpoint.Paths); err != nil {
|
||||||
|
t.Fatalf("restore checkpoint archive for inspection: %v", err)
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(checkpointRoot, "world", "level.dat")); got != "live" {
|
||||||
|
t.Fatalf("checkpoint file = %q, want live", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreAbortsIfCheckpointCreationFails(t *testing.T) {
|
||||||
|
cfg, serverRoot := setupBackupTest(t)
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "target")
|
||||||
|
|
||||||
|
target, err := Create(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create target backup: %v", err)
|
||||||
|
}
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "live")
|
||||||
|
|
||||||
|
checkpointErr := errors.New("save-off failed")
|
||||||
|
getServerPID = func() (int, bool) { return 123, true }
|
||||||
|
runSaveOff = func() error { return checkpointErr }
|
||||||
|
|
||||||
|
stopped := false
|
||||||
|
stopServerAndWait = func(time.Duration) error {
|
||||||
|
stopped = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
started := false
|
||||||
|
startServerReady = func(*state.Config) error {
|
||||||
|
started = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Restore(cfg, target.ID); err == nil {
|
||||||
|
t.Fatalf("Restore succeeded, want checkpoint failure")
|
||||||
|
}
|
||||||
|
if stopped {
|
||||||
|
t.Fatalf("server stop was called after checkpoint failure")
|
||||||
|
}
|
||||||
|
if started {
|
||||||
|
t.Fatalf("server start was called after checkpoint failure")
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(serverRoot, "world", "level.dat")); got != "live" {
|
||||||
|
t.Fatalf("live file changed after checkpoint failure: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualBackupIsManualAndCheckpointIsListable(t *testing.T) {
|
||||||
|
cfg, serverRoot := setupBackupTest(t)
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "target")
|
||||||
|
|
||||||
|
manual, err := Create(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Create manual backup: %v", err)
|
||||||
|
}
|
||||||
|
if manual.Type != BackupTypeManual {
|
||||||
|
t.Fatalf("manual type = %q, want %q", manual.Type, BackupTypeManual)
|
||||||
|
}
|
||||||
|
if manual.Reason != "" {
|
||||||
|
t.Fatalf("manual reason = %q, want empty", manual.Reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "live")
|
||||||
|
startServerReady = func(*state.Config) error { return nil }
|
||||||
|
result, err := Restore(cfg, manual.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backups, err := List()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("List: %v", err)
|
||||||
|
}
|
||||||
|
seen := map[string]Manifest{}
|
||||||
|
for _, manifest := range backups {
|
||||||
|
seen[manifest.ID] = manifest
|
||||||
|
}
|
||||||
|
if seen[manual.ID].Type != BackupTypeManual {
|
||||||
|
t.Fatalf("listed manual type = %q, want %q", seen[manual.ID].Type, BackupTypeManual)
|
||||||
|
}
|
||||||
|
checkpoint := seen[result.Checkpoint.ID]
|
||||||
|
if checkpoint.Type != BackupTypeCheckpoint || checkpoint.Reason != BackupReasonPreRestore {
|
||||||
|
t.Fatalf("listed checkpoint = type %q reason %q", checkpoint.Type, checkpoint.Reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreArchiveRejectsUnsafeManifestPathBeforeDeleting(t *testing.T) {
|
||||||
|
serverRoot := t.TempDir()
|
||||||
|
archiveRoot := t.TempDir()
|
||||||
|
mustWriteFile(t, filepath.Join(serverRoot, "world", "level.dat"), "live")
|
||||||
|
mustWriteFile(t, filepath.Join(archiveRoot, "world", "level.dat"), "target")
|
||||||
|
|
||||||
|
manifest := Manifest{
|
||||||
|
ID: "safe",
|
||||||
|
Paths: []string{"world"},
|
||||||
|
}
|
||||||
|
archivePath := filepath.Join(t.TempDir(), "safe.tar.gz")
|
||||||
|
if err := writeArchive(archiveRoot, archivePath, &manifest); err != nil {
|
||||||
|
t.Fatalf("writeArchive: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := restoreArchive(serverRoot, archivePath, []string{"../world"}); err == nil {
|
||||||
|
t.Fatalf("restoreArchive succeeded with unsafe path")
|
||||||
|
}
|
||||||
|
if got := mustReadFile(t, filepath.Join(serverRoot, "world", "level.dat")); got != "live" {
|
||||||
|
t.Fatalf("live file changed after unsafe path rejection: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupBackupTest(t *testing.T) (*state.Config, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldRootDir := rootDir
|
||||||
|
oldServerDir := serverDir
|
||||||
|
oldGetServerPID := getServerPID
|
||||||
|
oldRunSaveOff := runSaveOff
|
||||||
|
oldRunSaveOn := runSaveOn
|
||||||
|
oldStopServerAndWait := stopServerAndWait
|
||||||
|
oldStartServerReady := startServerReady
|
||||||
|
|
||||||
|
rootDir = t.TempDir()
|
||||||
|
serverRoot := t.TempDir()
|
||||||
|
serverDir = func(state.Config) string { return serverRoot }
|
||||||
|
getServerPID = func() (int, bool) { return 0, false }
|
||||||
|
runSaveOff = func() error { return nil }
|
||||||
|
runSaveOn = func() error { return nil }
|
||||||
|
stopServerAndWait = func(time.Duration) error { return nil }
|
||||||
|
startServerReady = func(*state.Config) error { return nil }
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
rootDir = oldRootDir
|
||||||
|
serverDir = oldServerDir
|
||||||
|
getServerPID = oldGetServerPID
|
||||||
|
runSaveOff = oldRunSaveOff
|
||||||
|
runSaveOn = oldRunSaveOn
|
||||||
|
stopServerAndWait = oldStopServerAndWait
|
||||||
|
startServerReady = oldStartServerReady
|
||||||
|
})
|
||||||
|
|
||||||
|
return &state.Config{
|
||||||
|
ContainerType: "game",
|
||||||
|
Game: "minecraft",
|
||||||
|
Variant: "vanilla",
|
||||||
|
Version: "1.21.4",
|
||||||
|
VMID: 5001,
|
||||||
|
World: "world",
|
||||||
|
}, serverRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustWriteFile(t *testing.T, path, data string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustReadFile(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFile(%s): %v", path, err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
@ -11,6 +11,9 @@ import (
|
|||||||
"zlh-agent/internal/state"
|
"zlh-agent/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var loadBackupConfig = state.LoadConfig
|
||||||
|
var restoreBackup = agentbackup.Restore
|
||||||
|
|
||||||
func HandleGameBackups(w http.ResponseWriter, r *http.Request) {
|
func HandleGameBackups(w http.ResponseWriter, r *http.Request) {
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodGet:
|
case http.MethodGet:
|
||||||
@ -115,16 +118,20 @@ func HandleGameBackupRestore(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
manifest, err := agentbackup.Restore(cfg, id)
|
result, err := restoreBackup(cfg, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"restored": true, "backup": manifest})
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"restored": true,
|
||||||
|
"backup": result.Backup,
|
||||||
|
"checkpoint": result.Checkpoint,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireBackupConfig(w http.ResponseWriter) (*state.Config, bool) {
|
func requireBackupConfig(w http.ResponseWriter) (*state.Config, bool) {
|
||||||
cfg, err := state.LoadConfig()
|
cfg, err := loadBackupConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONError(w, http.StatusBadRequest, "no config loaded")
|
writeJSONError(w, http.StatusBadRequest, "no config loaded")
|
||||||
return nil, false
|
return nil, false
|
||||||
|
|||||||
73
internal/handlers/backups_test.go
Normal file
73
internal/handlers/backups_test.go
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
agentbackup "zlh-agent/internal/backup"
|
||||||
|
"zlh-agent/internal/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleGameBackupRestoreIncludesCheckpoint(t *testing.T) {
|
||||||
|
oldLoadBackupConfig := loadBackupConfig
|
||||||
|
oldRestoreBackup := restoreBackup
|
||||||
|
loadBackupConfig = func() (*state.Config, error) {
|
||||||
|
return &state.Config{
|
||||||
|
ContainerType: "game",
|
||||||
|
Game: "minecraft",
|
||||||
|
Variant: "vanilla",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
restoreBackup = func(_ *state.Config, id string) (agentbackup.RestoreResult, error) {
|
||||||
|
return agentbackup.RestoreResult{
|
||||||
|
Backup: agentbackup.Manifest{
|
||||||
|
ID: id,
|
||||||
|
Type: agentbackup.BackupTypeManual,
|
||||||
|
},
|
||||||
|
Checkpoint: agentbackup.Manifest{
|
||||||
|
ID: "checkpoint-1",
|
||||||
|
Type: agentbackup.BackupTypeCheckpoint,
|
||||||
|
Reason: agentbackup.BackupReasonPreRestore,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
loadBackupConfig = oldLoadBackupConfig
|
||||||
|
restoreBackup = oldRestoreBackup
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/game/backups/restore", strings.NewReader(`{"id":"target-1"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
HandleGameBackupRestore(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Restored bool `json:"restored"`
|
||||||
|
Backup agentbackup.Manifest `json:"backup"`
|
||||||
|
Checkpoint agentbackup.Manifest `json:"checkpoint"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(rec.Body).Decode(&response); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if !response.Restored {
|
||||||
|
t.Fatalf("restored = false, want true")
|
||||||
|
}
|
||||||
|
if response.Backup.ID != "target-1" {
|
||||||
|
t.Fatalf("backup id = %q, want target-1", response.Backup.ID)
|
||||||
|
}
|
||||||
|
if response.Checkpoint.ID != "checkpoint-1" {
|
||||||
|
t.Fatalf("checkpoint id = %q, want checkpoint-1", response.Checkpoint.ID)
|
||||||
|
}
|
||||||
|
if response.Checkpoint.Type != agentbackup.BackupTypeCheckpoint {
|
||||||
|
t.Fatalf("checkpoint type = %q, want %q", response.Checkpoint.Type, agentbackup.BackupTypeCheckpoint)
|
||||||
|
}
|
||||||
|
if response.Checkpoint.Reason != agentbackup.BackupReasonPreRestore {
|
||||||
|
t.Fatalf("checkpoint reason = %q, want %q", response.Checkpoint.Reason, agentbackup.BackupReasonPreRestore)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -346,3 +346,16 @@
|
|||||||
2026/04/16 11:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
2026/04/16 11:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
2026/04/16 11:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
2026/04/16 11:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
2026/04/16 12:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
2026/04/16 12:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 12:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 13:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 13:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 14:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 14:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 15:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 15:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 16:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 16:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 17:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 17:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 18:06:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
2026/04/16 18:36:30 [update] notify check failed status=error current=0.0.0-dev target= err=Get "http://10.60.0.251:8080/agents/manifest.json": dial tcp 10.60.0.251:8080: connect: no route to host
|
||||||
|
|||||||
@ -2,5 +2,5 @@
|
|||||||
"status": "error",
|
"status": "error",
|
||||||
"current": "0.0.0-dev",
|
"current": "0.0.0-dev",
|
||||||
"error": "Get \"http://10.60.0.251:8080/agents/manifest.json\": dial tcp 10.60.0.251:8080: connect: no route to host",
|
"error": "Get \"http://10.60.0.251:8080/agents/manifest.json\": dial tcp 10.60.0.251:8080: connect: no route to host",
|
||||||
"checked_at_utc": "2026-04-16T12:06:27Z"
|
"checked_at_utc": "2026-04-16T18:36:27Z"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user