From 94bcdf2e780e8f96e68cf547cd90582f4d7083fb Mon Sep 17 00:00:00 2001 From: jester Date: Thu, 16 Apr 2026 18:53:08 +0000 Subject: [PATCH] backup updates 4-16-26 --- internal/backup/backup.go | 198 +++++++++++++++++++-------- internal/backup/backup_test.go | 220 ++++++++++++++++++++++++++++++ internal/handlers/backups.go | 13 +- internal/handlers/backups_test.go | 73 ++++++++++ logs/agent.log | 13 ++ state/update.json | 2 +- 6 files changed, 459 insertions(+), 60 deletions(-) create mode 100644 internal/backup/backup_test.go create mode 100644 internal/handlers/backups_test.go diff --git a/internal/backup/backup.go b/internal/backup/backup.go index 2c37156..280cc76 100644 --- a/internal/backup/backup.go +++ b/internal/backup/backup.go @@ -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 err := prune(defaultMaxCount); 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 @@ -132,8 +232,8 @@ func Delete(id string) error { archiveName = filepath.Base(manifest.Archive) } - archivePath := filepath.Join(RootDir, archiveName) - manifestPath := filepath.Join(RootDir, id+".json") + archivePath := filepath.Join(rootDir, archiveName) + manifestPath := filepath.Join(rootDir, id+".json") archiveErr := os.Remove(archivePath) if archiveErr != nil && !errors.Is(archiveErr, os.ErrNotExist) { @@ -149,45 +249,6 @@ func Delete(id string) error { 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 { if cfg == nil { return fmt.Errorf("config required") @@ -394,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 } @@ -412,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 } @@ -424,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 } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000..e462be5 --- /dev/null +++ b/internal/backup/backup_test.go @@ -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) +} diff --git a/internal/handlers/backups.go b/internal/handlers/backups.go index 240cebe..7d5abbf 100644 --- a/internal/handlers/backups.go +++ b/internal/handlers/backups.go @@ -11,6 +11,9 @@ import ( "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: @@ -115,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 diff --git a/internal/handlers/backups_test.go b/internal/handlers/backups_test.go new file mode 100644 index 0000000..7807648 --- /dev/null +++ b/internal/handlers/backups_test.go @@ -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) + } +} diff --git a/logs/agent.log b/logs/agent.log index 0f941d9..e2af5a5 100644 --- a/logs/agent.log +++ b/logs/agent.log @@ -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: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 diff --git a/state/update.json b/state/update.json index 971c9ec..b082969 100644 --- a/state/update.json +++ b/state/update.json @@ -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-16T12:06:27Z" + "checked_at_utc": "2026-04-16T18:36:27Z" } \ No newline at end of file