feat(backup): 归档先写 .partial 再 fsync 改名并记录 sha256,删除原件前回读校验,reaper 抽样巡检与清扫半截归档,保留权限与 mtime,面板标出损坏与已校验
This commit is contained in:
25 files changed
+1489
-125
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user