Compare commits
3 Commits
5036482f17
...
94bcdf2e78
| Author | SHA1 | Date | |
|---|---|---|---|
| 94bcdf2e78 | |||
| 1e9facacff | |||
| 12b4e514aa |
@ -22,11 +22,18 @@ const (
|
||||
RootDir = "/opt/zlh-agent/backups"
|
||||
defaultMaxCount = 10
|
||||
manifestName = "backup_manifest.json"
|
||||
|
||||
BackupTypeManual = "manual"
|
||||
BackupTypeCheckpoint = "checkpoint"
|
||||
|
||||
BackupReasonPreRestore = "pre_restore"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAtUTC string `json:"created_at_utc"`
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
ContainerType string `json:"container_type"`
|
||||
Game string `json:"game"`
|
||||
Variant string `json:"variant"`
|
||||
@ -38,31 +45,75 @@ type Manifest struct {
|
||||
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) {
|
||||
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 {
|
||||
return Manifest{}, err
|
||||
}
|
||||
if err := os.MkdirAll(RootDir, 0o755); err != nil {
|
||||
if err := os.MkdirAll(rootDir, 0o755); err != nil {
|
||||
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"
|
||||
archivePath := filepath.Join(RootDir, archiveName)
|
||||
serverRoot := provision.ServerDir(*cfg)
|
||||
archivePath := filepath.Join(rootDir, archiveName)
|
||||
serverRoot := serverDir(*cfg)
|
||||
paths := defaultPaths(cfg, serverRoot)
|
||||
|
||||
_, running := system.GetServerPID()
|
||||
backupType := strings.TrimSpace(opts.backupType)
|
||||
if backupType == "" {
|
||||
backupType = BackupTypeManual
|
||||
}
|
||||
|
||||
_, running := getServerPID()
|
||||
saveOff := false
|
||||
if running {
|
||||
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)
|
||||
}
|
||||
saveOff = true
|
||||
defer func() {
|
||||
if saveOff {
|
||||
_ = system.RunMinecraftSaveOn()
|
||||
_ = runSaveOn()
|
||||
}
|
||||
}()
|
||||
}
|
||||
@ -71,6 +122,8 @@ func Create(cfg *state.Config) (Manifest, error) {
|
||||
manifest := Manifest{
|
||||
ID: id,
|
||||
CreatedAtUTC: time.Now().UTC().Format(time.RFC3339),
|
||||
Type: backupType,
|
||||
Reason: strings.TrimSpace(opts.reason),
|
||||
ContainerType: cfg.ContainerType,
|
||||
Game: cfg.Game,
|
||||
Variant: cfg.Variant,
|
||||
@ -83,7 +136,7 @@ func Create(cfg *state.Config) (Manifest, error) {
|
||||
return Manifest{}, err
|
||||
}
|
||||
if saveOff {
|
||||
if err := system.RunMinecraftSaveOn(); err != nil {
|
||||
if err := runSaveOn(); err != nil {
|
||||
return Manifest{}, fmt.Errorf("enable minecraft saves: %w", err)
|
||||
}
|
||||
saveOff = false
|
||||
@ -91,14 +144,61 @@ func Create(cfg *state.Config) (Manifest, error) {
|
||||
if err := writeManifestSidecar(manifest); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
if opts.prune {
|
||||
if err := prune(defaultMaxCount); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
}
|
||||
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) {
|
||||
entries, err := os.ReadDir(RootDir)
|
||||
entries, err := os.ReadDir(rootDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return []Manifest{}, nil
|
||||
@ -121,43 +221,32 @@ func List() ([]Manifest, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func Restore(cfg *state.Config, id string) (Manifest, error) {
|
||||
if err := requireMinecraft(cfg); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
func Delete(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if !safeID(id) {
|
||||
return Manifest{}, fmt.Errorf("invalid backup id")
|
||||
return 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
|
||||
archiveName := id + ".tar.gz"
|
||||
if manifest, err := readManifestSidecar(id); err == nil && strings.TrimSpace(manifest.Archive) != "" {
|
||||
archiveName = filepath.Base(manifest.Archive)
|
||||
}
|
||||
|
||||
if _, running := system.GetServerPID(); running {
|
||||
state.SetOperationMessage("stopping server before restore")
|
||||
if err := system.StopServerAndWait(30 * time.Second); err != nil {
|
||||
return Manifest{}, err
|
||||
}
|
||||
}
|
||||
archivePath := filepath.Join(rootDir, archiveName)
|
||||
manifestPath := filepath.Join(rootDir, id+".json")
|
||||
|
||||
state.SetOperationMessage("restoring backup archive")
|
||||
if err := restoreArchive(provision.ServerDir(*cfg), archivePath, manifest.Paths); err != nil {
|
||||
return Manifest{}, err
|
||||
archiveErr := os.Remove(archivePath)
|
||||
if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) {
|
||||
return archiveErr
|
||||
}
|
||||
|
||||
state.SetOperationMessage("starting server after restore")
|
||||
state.SetState(state.StateStarting)
|
||||
state.SetReadyState(false, "", "")
|
||||
if err := system.StartServerReady(cfg); err != nil {
|
||||
return Manifest{}, err
|
||||
manifestErr := os.Remove(manifestPath)
|
||||
if manifestErr != nil && !errors.Is(manifestErr, os.ErrNotExist) {
|
||||
return manifestErr
|
||||
}
|
||||
return manifest, nil
|
||||
if errors.Is(archiveErr, os.ErrNotExist) && errors.Is(manifestErr, os.ErrNotExist) {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireMinecraft(cfg *state.Config) error {
|
||||
@ -366,14 +455,36 @@ func writeManifestSidecar(manifest Manifest) error {
|
||||
return err
|
||||
}
|
||||
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) {
|
||||
if !safeID(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 {
|
||||
return Manifest{}, err
|
||||
}
|
||||
@ -384,6 +495,9 @@ func readManifestSidecar(id string) (Manifest, error) {
|
||||
if manifest.ID != id {
|
||||
return Manifest{}, fmt.Errorf("backup manifest id mismatch")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Type) == "" {
|
||||
manifest.Type = BackupTypeManual
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
@ -396,8 +510,8 @@ func prune(maxCount int) error {
|
||||
return err
|
||||
}
|
||||
for i := maxCount; i < len(backups); i++ {
|
||||
_ = os.Remove(filepath.Join(RootDir, backups[i].Archive))
|
||||
_ = os.Remove(filepath.Join(RootDir, backups[i].ID+".json"))
|
||||
_ = os.Remove(filepath.Join(rootDir, backups[i].Archive))
|
||||
_ = os.Remove(filepath.Join(rootDir, backups[i].ID+".json"))
|
||||
}
|
||||
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)
|
||||
}
|
||||
@ -2,13 +2,18 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
agentbackup "zlh-agent/internal/backup"
|
||||
"zlh-agent/internal/state"
|
||||
)
|
||||
|
||||
var loadBackupConfig = state.LoadConfig
|
||||
var restoreBackup = agentbackup.Restore
|
||||
|
||||
func HandleGameBackups(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
@ -20,6 +25,39 @@ func HandleGameBackups(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
func HandleGameBackupByID(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
writeJSONError(w, http.StatusMethodNotAllowed, "DELETE only")
|
||||
return
|
||||
}
|
||||
endOp, ok := beginHandlerOperation(w, "backup_delete", true, "deleting backup")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer endOp()
|
||||
|
||||
if _, ok := requireBackupConfig(w); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimPrefix(r.URL.Path, "/game/backups/")
|
||||
id = strings.TrimSpace(strings.Trim(id, "/"))
|
||||
if id == "" || strings.Contains(id, "/") {
|
||||
writeJSONError(w, http.StatusBadRequest, "backup id required")
|
||||
return
|
||||
}
|
||||
|
||||
if err := agentbackup.Delete(id); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
writeJSONError(w, http.StatusNotFound, "backup not found")
|
||||
return
|
||||
}
|
||||
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"deleted": true, "id": id})
|
||||
}
|
||||
|
||||
func handleGameBackupsList(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireBackupConfig(w); !ok {
|
||||
return
|
||||
@ -80,16 +118,20 @@ func HandleGameBackupRestore(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
manifest, err := agentbackup.Restore(cfg, id)
|
||||
result, err := restoreBackup(cfg, id)
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusInternalServerError, err.Error())
|
||||
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) {
|
||||
cfg, err := state.LoadConfig()
|
||||
cfg, err := loadBackupConfig()
|
||||
if err != nil {
|
||||
writeJSONError(w, http.StatusBadRequest, "no config loaded")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -886,6 +886,7 @@ func NewMux() *http.ServeMux {
|
||||
m.HandleFunc("/game/players", handleGamePlayers)
|
||||
m.HandleFunc("/game/backups", agenthandlers.HandleGameBackups)
|
||||
m.HandleFunc("/game/backups/restore", agenthandlers.HandleGameBackupRestore)
|
||||
m.HandleFunc("/game/backups/", agenthandlers.HandleGameBackupByID)
|
||||
m.HandleFunc("/game/mods", agenthandlers.HandleGameMods)
|
||||
m.HandleFunc("/game/mods/install", agenthandlers.HandleGameModsInstall)
|
||||
m.HandleFunc("/game/mods/", agenthandlers.HandleGameModByID)
|
||||
|
||||
@ -315,3 +315,47 @@
|
||||
2026/04/15 20:36:14 [update] periodic checks enabled (mode=notify interval=30m0s)
|
||||
2026/04/15 20:36:14 [agent] listening on :18888
|
||||
2026/04/15 20:36:27 [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/15 21: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/15 21: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/15 22: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/15 22: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/15 23: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/15 23: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 00: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 00: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 01: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 01: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 02: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 02: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 03: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 03: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 04: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 04: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 05: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 05: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 06: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 06: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 07: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 07: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 08: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 08: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 09: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 09: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 10: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 10: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: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 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",
|
||||
"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",
|
||||
"checked_at_utc": "2026-04-15T20:36:24Z"
|
||||
"checked_at_utc": "2026-04-16T18:36:27Z"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user