Files
CyberStrikeAI/internal/storage/cleaner.go
T
SycunandParallels 470eb5ead1 feat: add runtime storage cleanup with per-category retention (#310) (#313)
Runtime artifacts (agent workspaces, tool-output spill, C2 payloads, chat
uploads, workflow checkpoints, diagnostic logs) previously accumulated
without bound: most were only removed when a conversation or project was
deleted, and tmp/c2 plus workflow checkpoints were never removed at all.

Add a storage cleaner with named per-category tasks (Gitea-style), a
settings page tab, and a background sweep that is off by default so
upgrading never deletes existing data.

Safety properties, since mis-deleting live task data costs far more than
the disk saved:
- dry-run is the default; a real cleanup requires dry_run=false together
  with confirm=true at the API layer, not just a frontend dialog
- sessions active within active_grace_hours are always skipped, and a
  failed activity lookup skips conservatively (fail closed)
- directories whose conversation/project no longer exists are reclaimed
  as orphans after orphan_grace_days
- scanners never follow symlinks and every candidate path is confined to
  its category root; deletion renames to a .tmp-for-deletion marker first
  so a crash leaves recoverable residue instead of a half-deleted dir
- storage:* permissions are admin-only; without the grantSystemRolePermissions
  skip the default branch would have given operators an irreversible
  file-deletion right

Also fix two confirmed leaks: DeleteConversation left chat_uploads files
on disk (their rows already vanished via ON DELETE CASCADE), and workflow
checkpoints had no deletion path at all.

Co-authored-by: Parallels <parallels@kali-linux-2025-2.localdomain>
2026-09-25 14:46:23 +08:00

529 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package storage
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"cyberstrike-ai/internal/config"
"go.uber.org/zap"
)
// ErrCleanupInProgress 表示已有一轮清理在执行;handler 应映射为 409。
var ErrCleanupInProgress = errors.New("已有一轮存储清理正在执行")
// ErrUnknownCategory 表示请求里带了未注册的类别键。
var ErrUnknownCategory = errors.New("未知的清理类别")
// 判定结果原因,用于报表与日志可解释性。
const (
reasonExpired = "expired" // 超过保留期
reasonOrphan = "orphan" // 会话/项目已删除,目录残留
reasonLeftover = "leftover" // 上一轮清理崩溃残留的标记目录
reasonActive = "active" // 最近有活动,受保护
reasonRecent = "recent" // 尚未达到宽限期
reasonKept = "kept" // 未到期或该类别保留期为 0
reasonUnknown = "unknown" // 活跃状态查询失败,保守跳过
)
// Activity 查询会话/项目最近活动时间。实现方出错时清理会保守跳过该单元。
type Activity interface {
ConversationLastActivity(id string) (time.Time, bool, error)
ProjectLastActivity(id string) (time.Time, bool, error)
}
// CleanRequest 描述一次清理请求。
type CleanRequest struct {
// DryRun 为 true 时只统计不删除。
DryRun bool `json:"dry_run"`
// Categories 为空表示全部已启用类别。
Categories []string `json:"categories"`
// Trigger 取值 manual / schedule,仅用于审计与日志。
Trigger string `json:"trigger"`
}
// CategoryReport 是单个类别的统计与执行结果。
type CategoryReport struct {
Key string `json:"key"`
Label string `json:"label"`
Hint string `json:"hint"`
Root string `json:"root"`
Enabled bool `json:"enabled"`
RetentionDays int `json:"retention_days"`
// Missing 表示根目录尚未创建(系统还没产生过该类垃圾)。
Missing bool `json:"missing"`
Units int `json:"units"`
Bytes int64 `json:"bytes"`
ReclaimableUnits int `json:"reclaimable_units"`
ReclaimableBytes int64 `json:"reclaimable_bytes"`
OrphanUnits int `json:"orphan_units"`
SkippedActive int `json:"skipped_active"`
SkippedUnsafe int `json:"skipped_unsafe"`
RemovedUnits int `json:"removed_units"`
FreedBytes int64 `json:"freed_bytes"`
RemovedEmptyDirs int `json:"removed_empty_dirs"`
Errors []string `json:"errors,omitempty"`
}
// Totals 是全部类别的汇总。
type Totals struct {
Units int `json:"units"`
Bytes int64 `json:"bytes"`
ReclaimableUnits int `json:"reclaimable_units"`
ReclaimableBytes int64 `json:"reclaimable_bytes"`
RemovedUnits int `json:"removed_units"`
FreedBytes int64 `json:"freed_bytes"`
SkippedActive int `json:"skipped_active"`
Errors int `json:"errors"`
}
// Report 是一次统计或清理的完整结果。
type Report struct {
DryRun bool `json:"dry_run"`
Trigger string `json:"trigger"`
StartedAt time.Time `json:"started_at"`
DurationMS int64 `json:"duration_ms"`
Filesystem Filesystem `json:"filesystem"`
Categories []CategoryReport `json:"categories"`
Totals Totals `json:"totals"`
Note string `json:"note,omitempty"`
}
// defaultCacheTTL 限制 status 接口的目录遍历频率:大工作区下一次全量 walk 可能耗时数秒。
const defaultCacheTTL = time.Minute
// Options 构造 Cleaner 所需依赖。
type Options struct {
Config *config.Config
Paths Paths
Activity Activity
Logger *zap.Logger
// Now 便于测试注入固定时钟;省略时使用 time.Now。
Now func() time.Time
// CacheTTL 省略时使用 defaultCacheTTL;<=0 表示禁用缓存。
CacheTTL time.Duration
}
// Cleaner 枚举、评估并删除运行空间垃圾。
type Cleaner struct {
cfg *config.Config
paths Paths
activity Activity
logger *zap.Logger
now func() time.Time
cacheTTL time.Duration
// running 保证同一时刻只有一轮清理,避免两个管理员同时点「立即清理」互相踩。
running atomic.Bool
mu sync.Mutex
cached *Report
cachedAt time.Time
}
// NewCleaner 创建清理器。cfg 为 nil 时使用零值配置(等价于全部默认策略)。
func NewCleaner(opts Options) *Cleaner {
c := &Cleaner{
cfg: opts.Config,
paths: opts.Paths,
activity: opts.Activity,
logger: opts.Logger,
now: opts.Now,
cacheTTL: opts.CacheTTL,
}
if c.cfg == nil {
c.cfg = &config.Config{}
}
if c.now == nil {
c.now = time.Now
}
if opts.CacheTTL == 0 {
c.cacheTTL = defaultCacheTTL
} else if opts.CacheTTL < 0 {
c.cacheTTL = 0
}
return c
}
// storageConfig 返回当前生效的存储策略(读取时取值,因此 PUT /api/config 后即时生效)。
func (c *Cleaner) storageConfig() config.StorageConfig {
if c.cfg == nil {
return config.StorageConfig{}
}
return c.cfg.Storage
}
// rootOf 返回类别根目录的绝对路径;未配置时返回空串。
func (c *Cleaner) rootOf(cat category) string {
root := strings.TrimSpace(cat.root(c.paths))
if root == "" {
return ""
}
abs, err := filepath.Abs(root)
if err != nil {
return ""
}
return filepath.Clean(abs)
}
// selectCategories 按注册表顺序返回待处理类别,保证报表顺序稳定。
// onlyEnabled 为 true 时跳过被显式关闭的类别。
func (c *Cleaner) selectCategories(keys []string, onlyEnabled bool) ([]category, error) {
all := categories()
if len(keys) == 0 {
out := make([]category, 0, len(all))
for _, cat := range all {
if onlyEnabled && !c.storageConfig().CategoryEnabled(cat.key) {
continue
}
out = append(out, cat)
}
return out, nil
}
wanted := make(map[string]bool, len(keys))
for _, k := range keys {
k = strings.TrimSpace(k)
if k == "" {
continue
}
wanted[k] = true
}
known := make(map[string]bool, len(all))
for _, cat := range all {
known[cat.key] = true
}
for k := range wanted {
if !known[k] {
return nil, fmt.Errorf("%w: %s", ErrUnknownCategory, k)
}
}
out := make([]category, 0, len(wanted))
for _, cat := range all {
if !wanted[cat.key] {
continue
}
if onlyEnabled && !c.storageConfig().CategoryEnabled(cat.key) {
continue
}
out = append(out, cat)
}
return out, nil
}
// scanResult 是一次类别扫描的产物:报表 + 可删除单元。
type scanResult struct {
report CategoryReport
eligible []Unit
}
// scan 枚举类别下全部单元并逐个判定,同时产出统计与可删除清单。
func (c *Cleaner) scan(cat category, now time.Time) scanResult {
st := c.storageConfig()
rep := CategoryReport{
Key: cat.key,
Label: cat.label,
Hint: cat.hint,
Root: c.rootOf(cat),
Enabled: st.CategoryEnabled(cat.key),
RetentionDays: st.CategoryRetentionDays(cat.key),
}
res := scanResult{report: rep}
if rep.Root == "" {
rep.Missing = true
res.report = rep
return res
}
if _, err := os.Stat(rep.Root); err != nil {
rep.Missing = true
res.report = rep
return res
}
units, err := cat.scan(rep.Root)
if err != nil {
rep.Errors = append(rep.Errors, err.Error())
res.report = rep
return res
}
for _, u := range units {
rep.Units++
rep.Bytes += u.Size
// 纵深防御:scanner 只会产出 root 之下的路径,此处再断言一次。
if !confined(rep.Root, u.Path) {
rep.SkippedUnsafe++
continue
}
d := c.evaluate(u, rep.RetentionDays, now)
if !d.eligible {
if d.reason == reasonActive || d.reason == reasonUnknown {
rep.SkippedActive++
}
continue
}
rep.ReclaimableUnits++
rep.ReclaimableBytes += u.Size
if d.reason == reasonOrphan {
rep.OrphanUnits++
}
res.eligible = append(res.eligible, u)
}
res.report = rep
return res
}
// decision 是单个单元的判定结果。
type decision struct {
eligible bool
reason string
}
// evaluate 判定单元是否可删除。
// 优先级:崩溃残留标记 > 最近活动保护 > 会话存活状态 > 保留期。
func (c *Cleaner) evaluate(u Unit, retentionDays int, now time.Time) decision {
// 上一轮清理中途崩溃留下的标记目录:无条件补删。
// 必须放在最前面,否则它的 mtime 是刚刚改名的时间,会被活跃保护永久挡住。
if strings.HasSuffix(filepath.Base(u.Path), deletionMarkerSuffix) {
return decision{eligible: true, reason: reasonLeftover}
}
st := c.storageConfig()
activeGrace := time.Duration(st.ActiveGraceHoursEffective()) * time.Hour
age := u.Age(now)
if age < activeGrace {
return decision{reason: reasonActive}
}
retention := time.Duration(retentionDays) * 24 * time.Hour
if u.Scope != ScopeNone && u.Session != "" && c.activity != nil {
last, exists, err := c.lastActivity(u)
if err != nil {
// 查不到活跃状态时保守跳过:宁可少删,不可误删正在跑的任务数据。
return decision{reason: reasonUnknown}
}
if exists {
if now.Sub(last) < activeGrace {
return decision{reason: reasonActive}
}
if retentionDays > 0 && age >= retention {
return decision{eligible: true, reason: reasonExpired}
}
return decision{reason: reasonKept}
}
// 会话/项目已不存在 → 孤儿目录,按较短的宽限期回收。
if age >= time.Duration(st.OrphanGraceDaysEffective())*24*time.Hour {
return decision{eligible: true, reason: reasonOrphan}
}
return decision{reason: reasonRecent}
}
// retention_days: 0 表示不按保留期清理(沿用本项目既有约定)。
if retentionDays <= 0 {
return decision{reason: reasonKept}
}
if age >= retention {
return decision{eligible: true, reason: reasonExpired}
}
return decision{reason: reasonKept}
}
func (c *Cleaner) lastActivity(u Unit) (time.Time, bool, error) {
switch u.Scope {
case ScopeConversation:
return c.activity.ConversationLastActivity(u.Session)
case ScopeProject:
return c.activity.ProjectLastActivity(u.Session)
default:
return time.Time{}, false, nil
}
}
// Inspect 统计全部类别的占用与可回收量,不删除任何文件。
// 结果按 CacheTTL 缓存,refresh 为 true 时强制重算。
func (c *Cleaner) Inspect(refresh bool) *Report {
c.mu.Lock()
if !refresh && c.cacheTTL > 0 && c.cached != nil && c.now().Sub(c.cachedAt) < c.cacheTTL {
cached := c.cached
c.mu.Unlock()
return cached
}
c.mu.Unlock()
rep := c.buildReport(CleanRequest{DryRun: true, Trigger: "inspect"}, true)
c.mu.Lock()
c.cached = rep
c.cachedAt = c.now()
c.mu.Unlock()
return rep
}
// invalidateCache 让下一次 Inspect 重新遍历。
func (c *Cleaner) invalidateCache() {
c.mu.Lock()
c.cached = nil
c.mu.Unlock()
}
// Clean 执行一次清理;DryRun 为 true 时只统计。
// 只处理已启用的类别,且同一时刻只允许一轮执行。
func (c *Cleaner) Clean(req CleanRequest) (*Report, error) {
if !c.running.CompareAndSwap(false, true) {
return nil, ErrCleanupInProgress
}
defer c.running.Store(false)
req.Trigger = strings.TrimSpace(req.Trigger)
if req.Trigger == "" {
req.Trigger = "manual"
}
if _, err := c.selectCategories(req.Categories, false); err != nil {
return nil, err
}
rep := c.buildReport(req, false)
c.invalidateCache()
if !req.DryRun {
c.logClean(rep, req)
}
return rep, nil
}
// buildReport 是 Inspect 与 Clean 的共用主体。
// inspectAll 为 true 时统计全部类别(含被关闭的),供状态页展示;
// 为 false 时只处理已启用类别并真正执行删除。
func (c *Cleaner) buildReport(req CleanRequest, inspectAll bool) *Report {
startedAt := c.now()
cats, err := c.selectCategories(req.Categories, !inspectAll)
rep := &Report{
DryRun: req.DryRun,
Trigger: req.Trigger,
StartedAt: startedAt,
}
if err != nil {
rep.Note = err.Error()
rep.Categories = []CategoryReport{}
return rep
}
for _, cat := range cats {
res := c.scan(cat, startedAt)
cr := res.report
if !req.DryRun && cr.Enabled {
root := cr.Root
for _, u := range res.eligible {
if rmErr := removeUnit(u); rmErr != nil {
cr.Errors = append(cr.Errors, fmt.Sprintf("%s: %v", filepath.Base(u.Path), rmErr))
continue
}
cr.RemovedUnits++
cr.FreedBytes += u.Size
}
if cat.pruneEmpty && cr.RemovedUnits > 0 && root != "" {
// 日期层 + 会话层,最多两层。
cr.RemovedEmptyDirs = pruneEmptyDirs(root, 2)
}
}
rep.Categories = append(rep.Categories, cr)
}
if rep.Categories == nil {
rep.Categories = []CategoryReport{}
}
for _, cr := range rep.Categories {
rep.Totals.Units += cr.Units
rep.Totals.Bytes += cr.Bytes
rep.Totals.ReclaimableUnits += cr.ReclaimableUnits
rep.Totals.ReclaimableBytes += cr.ReclaimableBytes
rep.Totals.RemovedUnits += cr.RemovedUnits
rep.Totals.FreedBytes += cr.FreedBytes
rep.Totals.SkippedActive += cr.SkippedActive
rep.Totals.Errors += len(cr.Errors)
}
rep.Filesystem = c.filesystem()
rep.DurationMS = time.Since(startedAt).Milliseconds()
return rep
}
// filesystem 取第一个存在的类别根目录所在文件系统,作为概览卡片的容量来源。
func (c *Cleaner) filesystem() Filesystem {
probe := ""
for _, cat := range categories() {
if root := c.rootOf(cat); root != "" {
probe = root
break
}
}
if probe == "" {
probe = "."
}
fs, err := FilesystemUsage(probe)
if err != nil && c.logger != nil {
c.logger.Debug("查询文件系统容量失败", zap.String("path", probe), zap.Error(err))
}
return fs
}
// removeUnit 删除单元。目录先原子改名再递归删除:
// 中途崩溃只会留下带 deletionMarkerSuffix 的目录,下一轮 evaluate 会无条件补删。
func removeUnit(u Unit) error {
if u.IsDir {
marker := u.Path + deletionMarkerSuffix
if err := os.Rename(u.Path, marker); err == nil {
return ignoreMissing(os.RemoveAll(marker))
}
// 改名失败(跨设备、权限、同名残留)时退化为直接删除。
}
return ignoreMissing(os.RemoveAll(u.Path))
}
func ignoreMissing(err error) error {
if err == nil || os.IsNotExist(err) {
return nil
}
return err
}
func (c *Cleaner) logClean(rep *Report, req CleanRequest) {
if c.logger == nil {
return
}
summary := []zap.Field{
zap.String("trigger", req.Trigger),
zap.Int("removed_units", rep.Totals.RemovedUnits),
zap.Int64("freed_bytes", rep.Totals.FreedBytes),
zap.Int("skipped_active", rep.Totals.SkippedActive),
zap.Int("errors", rep.Totals.Errors),
}
if rep.Totals.RemovedUnits == 0 && rep.Totals.Errors == 0 {
c.logger.Debug("运行空间清理完成,无可回收内容", summary...)
return
}
c.logger.Info("运行空间清理完成", summary...)
for _, cr := range rep.Categories {
if cr.RemovedUnits == 0 && len(cr.Errors) == 0 {
continue
}
c.logger.Info("清理类别明细",
zap.String("category", cr.Key),
zap.Int("removed", cr.RemovedUnits),
zap.Int64("freed_bytes", cr.FreedBytes),
zap.Strings("errors", cr.Errors))
}
}