backup updates 4-16-26

This commit is contained in:
jester 2026-04-16 18:53:08 +00:00
parent 1e9facacff
commit 94bcdf2e78
6 changed files with 459 additions and 60 deletions

View File

@ -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
} }

View 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)
}

View File

@ -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

View 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)
}
}

View File

@ -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

View File

@ -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"
} }