feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check, схема и состав данных, сверка с манифестом; живые данные не трогает - чистка удаляет файлы на диске, у которых не осталось записи в базе (так оставались вложения удалённых серверов), и пустые подкаталоги; работает через os.Root, чтобы символическая ссылка не вывела за каталог файлов - удаление сервера теперь чистит и содержимое файлов, а не только записи - install.sh пишет значения .env с пробелами в кавычках: файл подключается через `set -a && . .env`, иначе имя главного сервера выполнялось как команда - тесты: Go на уборку файлов без записи (свежие не трогаются, пустые каталоги удаляются)
This commit is contained in:
@@ -36,6 +36,8 @@ type Stats struct {
|
||||
Sessions int64
|
||||
Audit int64
|
||||
Files int64
|
||||
// Unknown — файлы на диске, у которых не осталось записи в базе.
|
||||
Unknown int64
|
||||
// Bytes — сколько освободили на диске.
|
||||
Bytes int64
|
||||
}
|
||||
@@ -104,6 +106,18 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Файлы на диске без записи в базе: так остаются вложения удалённых
|
||||
// серверов — запись уходит каскадом, а содержимое лежит мусором.
|
||||
if r.options.OrphanHours > 0 {
|
||||
cutoff := r.now().Add(-time.Duration(r.options.OrphanHours) * time.Hour)
|
||||
removed, bytes, err := r.pruneUnknownFiles(ctx, cutoff)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("unknown files: %w", err))
|
||||
}
|
||||
stats.Unknown = removed
|
||||
stats.Bytes += bytes
|
||||
}
|
||||
|
||||
stats2 := stats
|
||||
if len(errs) > 0 {
|
||||
return stats2, errors.Join(errs...)
|
||||
@@ -111,6 +125,89 @@ func (r *Runner) RunOnce(ctx context.Context) (Stats, error) {
|
||||
return stats2, nil
|
||||
}
|
||||
|
||||
// pruneUnknownFiles удаляет с диска файлы, которых нет в базе, если они старше
|
||||
// cutoff. Возвращает число файлов и освобождённые байты.
|
||||
func (r *Runner) pruneUnknownFiles(ctx context.Context, cutoff time.Time) (int64, int64, error) {
|
||||
known, err := r.store.FileStoragePaths(ctx)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
// Каталог файлов: пути в базе абсолютные, как их пишет загрузка.
|
||||
root := filepath.Join(r.options.DataDir, "files")
|
||||
// os.Root ограничивает работу каталогом файлов: подменённая символическая
|
||||
// ссылка не выведет удаление за его пределы (AGENT.md 9.2).
|
||||
dir, err := os.OpenRoot(root)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return 0, 0, nil
|
||||
}
|
||||
return 0, 0, err
|
||||
}
|
||||
defer func() { _ = dir.Close() }()
|
||||
|
||||
var (
|
||||
removed int64
|
||||
bytes int64
|
||||
errs []error
|
||||
)
|
||||
walkErr := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() || known[path] {
|
||||
return nil
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
if info.ModTime().After(cutoff) {
|
||||
// Свежий файл мог ещё не получить запись: не трогаем.
|
||||
return nil
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
if err := dir.Remove(relative); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
errs = append(errs, err)
|
||||
return nil
|
||||
}
|
||||
removed++
|
||||
bytes += info.Size()
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
errs = append(errs, walkErr)
|
||||
}
|
||||
// Пустые каталоги после уборки не нужны: их создаёт раскладка по префиксам.
|
||||
if removed > 0 {
|
||||
pruneEmptyDirs(dir, root)
|
||||
}
|
||||
return removed, bytes, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// pruneEmptyDirs удаляет пустые подкаталоги, начиная с самых глубоких.
|
||||
// Ошибки игнорируются: каталог мог исчезнуть или быть непустым — это норма.
|
||||
func pruneEmptyDirs(dir *os.Root, root string) {
|
||||
dirs := make([]string, 0, 16)
|
||||
_ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err == nil && entry.IsDir() && path != root {
|
||||
dirs = append(dirs, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
relative, err := filepath.Rel(root, dirs[i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
_ = dir.Remove(relative)
|
||||
}
|
||||
}
|
||||
|
||||
// Run запускает чистку по расписанию; возвращает функцию остановки.
|
||||
func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
||||
if interval <= 0 {
|
||||
@@ -137,6 +234,7 @@ func (r *Runner) Run(ctx context.Context, interval time.Duration) func() {
|
||||
slog.Int64("sessions", stats.Sessions),
|
||||
slog.Int64("audit", stats.Audit),
|
||||
slog.Int64("files", stats.Files),
|
||||
slog.Int64("unknown_files", stats.Unknown),
|
||||
slog.Int64("bytes", stats.Bytes))
|
||||
}
|
||||
}
|
||||
@@ -169,6 +267,8 @@ func (r *Runner) removeFile(file store.File) (int64, error) {
|
||||
}
|
||||
|
||||
// storagePath проверяет, что путь лежит внутри каталога данных (AGENT.md 9.2).
|
||||
// Файлы на диске лежат плоско в `<DataDir>/files`, поэтому проверка сводится
|
||||
// к сравнению с этим каталогом.
|
||||
func (r *Runner) storagePath(stored string) (string, error) {
|
||||
root := filepath.Join(r.options.DataDir, "files")
|
||||
clean := filepath.Clean(stored)
|
||||
|
||||
@@ -209,3 +209,49 @@ func TestCleanupKeepsFreshOrphansAndRecentAudit(t *testing.T) {
|
||||
t.Fatalf("свежий файл удалён: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupRemovesFilesWithoutRecords(t *testing.T) {
|
||||
st, _, dataDir := newRetentionStore(t)
|
||||
ctx := context.Background()
|
||||
filesDir := filepath.Join(dataDir, "files")
|
||||
nested := filepath.Join(filesDir, "ab")
|
||||
if err := os.MkdirAll(nested, 0o700); err != nil {
|
||||
t.Fatalf("prepare files dir: %v", err)
|
||||
}
|
||||
|
||||
// Файл на диске, записи о котором нет: так остаются вложения удалённых
|
||||
// серверов (запись уходит каскадом, содержимое — нет).
|
||||
orphan := filepath.Join(nested, "orphan.bin")
|
||||
if err := os.WriteFile(orphan, []byte("мусор"), 0o600); err != nil {
|
||||
t.Fatalf("write orphan: %v", err)
|
||||
}
|
||||
old := time.Now().Add(-48 * time.Hour)
|
||||
if err := os.Chtimes(orphan, old, old); err != nil {
|
||||
t.Fatalf("touch orphan: %v", err)
|
||||
}
|
||||
|
||||
// Свежий файл без записи не трогаем: он может ещё не получить запись.
|
||||
fresh := filepath.Join(filesDir, "fresh.bin")
|
||||
if err := os.WriteFile(fresh, []byte("новый"), 0o600); err != nil {
|
||||
t.Fatalf("write fresh: %v", err)
|
||||
}
|
||||
|
||||
runner := New(st, Options{DataDir: dataDir, AuditDays: 30, OrphanHours: 24})
|
||||
stats, err := runner.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup: %v", err)
|
||||
}
|
||||
if stats.Unknown != 1 {
|
||||
t.Fatalf("удалено файлов без записи: %d, ожидался 1", stats.Unknown)
|
||||
}
|
||||
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
|
||||
t.Fatalf("файл без записи остался: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(fresh); err != nil {
|
||||
t.Fatalf("свежий файл удалён: %v", err)
|
||||
}
|
||||
// Пустой подкаталог после уборки удалён.
|
||||
if _, err := os.Stat(nested); !os.IsNotExist(err) {
|
||||
t.Fatalf("пустой каталог остался: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -1686,9 +1687,22 @@ func (s *Server) deleteGuild(ctx context.Context, actor *store.User, guildID uin
|
||||
if guild.IsMain {
|
||||
return humaErrorStatus(http.StatusForbidden, "guild.main_protected", "main guild cannot be deleted")
|
||||
}
|
||||
// Пути файлов собираем до удаления: записи уходят каскадом, а содержимое
|
||||
// на диске осталось бы мусором (AGENT.md 6.4).
|
||||
storagePaths, err := s.store.GuildStoragePaths(ctx, guildID)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to list guild files",
|
||||
slog.String("guild_id", formatSnowflake(guildID)), slog.Any("error", err))
|
||||
}
|
||||
if err := s.store.DeleteGuild(ctx, guildID); err != nil {
|
||||
return humaError(err)
|
||||
}
|
||||
for _, path := range storagePaths {
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.WarnContext(ctx, "failed to remove guild file",
|
||||
slog.String("path", path), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
s.invalidateGuild(guildID)
|
||||
s.recordAudit(ctx, actor, 0, "guild.delete", "guild", &guildID, "")
|
||||
if s.gateway != nil {
|
||||
|
||||
@@ -164,3 +164,44 @@ func scanFile(scanner interface{ Scan(...any) error }) (*File, error) {
|
||||
file.CreatedAt = parseTimestamp(createdAt)
|
||||
return &file, nil
|
||||
}
|
||||
|
||||
// FileStoragePaths возвращает пути всех файлов, известных базе: по ним
|
||||
// обслуживание понимает, какие файлы на диске остались без записи.
|
||||
func (s *Store) FileStoragePaths(ctx context.Context) (map[string]bool, error) {
|
||||
rows, err := s.reader.QueryContext(ctx, `SELECT storage_path FROM files WHERE storage_path <> ''`)
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
paths := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var path string
|
||||
if err := rows.Scan(&path); err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
paths[path] = true
|
||||
}
|
||||
return paths, rows.Err()
|
||||
}
|
||||
|
||||
// GuildStoragePaths возвращает пути файлов сервера: их нужно удалить с диска
|
||||
// перед удалением сервера, иначе содержимое останется мусором (AGENT.md 6.4).
|
||||
func (s *Store) GuildStoragePaths(ctx context.Context, guildID uint64) ([]string, error) {
|
||||
rows, err := s.reader.QueryContext(ctx,
|
||||
`SELECT storage_path FROM files WHERE guild_id = ? AND storage_path <> ''`, int64(guildID))
|
||||
if err != nil {
|
||||
return nil, mapError(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
paths := make([]string, 0, 8)
|
||||
for rows.Next() {
|
||||
var path string
|
||||
if err := rows.Scan(&path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
return paths, rows.Err()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user