feat(backup): 归档先写 .partial 再 fsync 改名并记录 sha256,删除原件前回读校验,reaper 抽样巡检与清扫半截归档,保留权限与 mtime,面板标出损坏与已校验

This commit is contained in:
Lemon-miaow committed 2026-09-25 03:16:06 +08:00
1 parent 489eff4494
commit 89a0134707
25 files changed
+1489 -125

No files matched your search

+2 -2
View File
@@ -806,7 +806,7 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe
var latest *fakeBackup
for i := range f.backups {
b := &f.backups[i]
if b.view.Status != "present" || b.view.ServerName != serverName {
if b.view.Status != "present" || b.view.ServerName != serverName || b.view.Corrupt {
continue
}
if latest == nil || b.view.CreatedAt.After(latest.view.CreatedAt) {
@@ -830,7 +830,7 @@ func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, erro
return &BackupRecord{
ID: b.view.ID, ServerName: b.view.ServerName,
FormerOwner: b.view.FormerOwner, BackupRef: b.ref,
SizeBytes: b.view.SizeBytes,
SizeBytes: b.view.SizeBytes, Corrupt: b.view.Corrupt,
}, nil
}
}
+7
View File
@@ -141,6 +141,13 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) {
writeError(w, r, errForbidden)
return
}
// The reaper found this archive damaged when it read it back; the restore
// Job would refuse it too, but only after the world was locked for it.
if backup.Corrupt {
writeError(w, r, newError(http.StatusConflict, "backup_corrupt",
"this backup did not read back intact and cannot be restored; pick another"))
return
}
} else {
backup, err = a.Repo.LatestBackup(r.Context(), name)
if err != nil {
+23
View File
@@ -414,6 +414,29 @@ func TestRestoreBackup(t *testing.T) {
}
})
t.Run("restore by backup_id that failed a read-back -> 409 backup_corrupt", func(t *testing.T) {
api, repo, _, restorer := mkTwo()
repo.backups[1].view.Corrupt = true
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", path, `{"backup_id":"bk2"}`, jsonHeaders)
if w.Code != http.StatusConflict || decodeErr(t, w) != "backup_corrupt" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
if restorer.calls != 0 {
t.Fatal("a corrupt backup reached the restorer")
}
})
t.Run("no body skips a latest backup that failed a read-back", func(t *testing.T) {
api, repo, _, restorer := mkTwo()
repo.backups[0].view.Corrupt = true
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", path, "", nil)
if w.Code != http.StatusAccepted || restorer.gotRef != "ref-bk2" {
t.Fatalf("code = %d ref %q, want 202 restoring the newest intact backup", w.Code, restorer.gotRef)
}
})
t.Run("no body -> falls back to LatestBackup (backward compat)", func(t *testing.T) {
api, _, _, restorer := mkTwo()
api.External = staticExternal{p: owner}
+15 -9
View File
@@ -675,7 +675,7 @@ func (p *PGRepo) SeedServer(ctx context.Context, name, subdomain string, cpuMill
// listed — an expired or deleted backup is gone (spec §466).
func (p *PGRepo) AllBackups(ctx context.Context) ([]BackupView, error) {
const q = `SELECT id, server_name, COALESCE(former_owner, ''), COALESCE(size_bytes, 0),
reason, status, created_at, expires_at
reason, status, created_at, expires_at, corrupt_at IS NOT NULL, verified_at, skipped_entries
FROM world_backups WHERE status = 'present' ORDER BY created_at DESC`
rows, err := p.db.QueryContext(ctx, q)
if err != nil {
@@ -689,7 +689,7 @@ func (p *PGRepo) AllBackups(ctx context.Context) ([]BackupView, error) {
// former_owner never matches a user id, so orphaned backups stay admin-only.
func (p *PGRepo) BackupsForUser(ctx context.Context, userID string) ([]BackupView, error) {
const q = `SELECT id, server_name, COALESCE(former_owner, ''), COALESCE(size_bytes, 0),
reason, status, created_at, expires_at
reason, status, created_at, expires_at, corrupt_at IS NOT NULL, verified_at, skipped_entries
FROM world_backups WHERE status = 'present' AND former_owner = $1 ORDER BY created_at DESC`
rows, err := p.db.QueryContext(ctx, q, userID)
if err != nil {
@@ -705,21 +705,26 @@ func scanBackupViews(rows *sql.Rows) ([]BackupView, error) {
var out []BackupView
for rows.Next() {
var v BackupView
var verified sql.NullTime
if err := rows.Scan(&v.ID, &v.ServerName, &v.FormerOwner, &v.SizeBytes,
&v.Reason, &v.Status, &v.CreatedAt, &v.ExpiresAt); err != nil {
&v.Reason, &v.Status, &v.CreatedAt, &v.ExpiresAt, &v.Corrupt, &verified, &v.SkippedEntries); err != nil {
return nil, err
}
if verified.Valid {
v.VerifiedAt = &verified.Time
}
out = append(out, v)
}
return out, rows.Err()
}
// LatestBackup returns the most recent present backup for a server (spec §466
// restore), or ErrNotFound. Unlike the list queries this selects backup_ref — the
// caller (the restore handler) hands it to the Restorer and never serializes it.
// LatestBackup returns the most recent present backup for a server that has not
// failed a read-back (spec §466 restore), or ErrNotFound. Unlike the list queries
// this selects backup_ref — the caller (the restore handler) hands it to the
// Restorer and never serializes it.
func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error) {
const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0)
FROM world_backups WHERE server_name = $1 AND status = 'present'
FROM world_backups WHERE server_name = $1 AND status = 'present' AND corrupt_at IS NULL
ORDER BY created_at DESC LIMIT 1`
var b BackupRecord
switch err := p.db.QueryRowContext(ctx, q, serverName).Scan(
@@ -734,11 +739,12 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe
// BackupByID returns a single present backup by its id, or ErrNotFound.
func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, error) {
const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0)
const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0),
corrupt_at IS NOT NULL
FROM world_backups WHERE id = $1 AND status = 'present'`
var b BackupRecord
switch err := p.db.QueryRowContext(ctx, q, id).Scan(
&b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes); {
&b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes, &b.Corrupt); {
case errors.Is(err, sql.ErrNoRows):
return nil, ErrNotFound
case err != nil:
+9
View File
@@ -69,6 +69,13 @@ type BackupView struct {
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
// Corrupt reports that the archive failed a read-back: it cannot be
// restored. VerifiedAt is its last read-back that matched, and
// SkippedEntries the world entries it could not hold (symbolic links,
// devices, sockets).
Corrupt bool `json:"corrupt,omitempty"`
VerifiedAt *time.Time `json:"verified_at,omitempty"`
SkippedEntries int `json:"skipped_entries,omitempty"`
}
// BackupRecord is the server-side view of a backup used to drive a restore (spec
@@ -81,6 +88,8 @@ type BackupRecord struct {
FormerOwner string
BackupRef string
SizeBytes int64
// Corrupt reports that the archive failed a read-back (see BackupView).
Corrupt bool
}
// StaffUser is the login-side projection of a users row (spec §B passwordless