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:
2026-09-21 23:33:04 +03:00
parent ef871bcd96
commit 1cb949a7a4
8 changed files with 329 additions and 4 deletions
+100
View File
@@ -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)