Track workspace size and age, expose an admin list and cleanup route, and refuse TTL or quota deletion until a frozen manifest matches verified receipts.
108 lines
2.7 KiB
Go
108 lines
2.7 KiB
Go
package workspace
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// GCReport is the outcome of one retention pass. Directories without a matching
|
|
// frozen manifest and verified receipts are left in place.
|
|
type GCReport struct {
|
|
Deleted []string `json:"deleted"`
|
|
Blocked []string `json:"blocked"`
|
|
Bytes int64 `json:"bytes"`
|
|
}
|
|
|
|
// CollectGarbage removes expired or over-quota workspaces only when
|
|
// AssertCleanupAllowed accepts the stored manifest and receipts.
|
|
func (r *FileRegistry) CollectGarbage(ctx context.Context, now time.Time, ttl time.Duration, quota int64, cap *Capability) (GCReport, error) {
|
|
if err := r.Discover(); err != nil {
|
|
return GCReport{}, err
|
|
}
|
|
list, err := r.List()
|
|
if err != nil {
|
|
return GCReport{}, err
|
|
}
|
|
if cap == nil {
|
|
cap = NewCapability(r.dataRoot, nil)
|
|
}
|
|
var report GCReport
|
|
for _, rec := range list {
|
|
report.Bytes += rec.Bytes
|
|
}
|
|
expired := map[string]bool{}
|
|
if ttl > 0 {
|
|
for _, rec := range list {
|
|
if now.Sub(rec.CreatedAt) > ttl {
|
|
expired[rec.ID] = true
|
|
}
|
|
}
|
|
}
|
|
over := quota > 0 && report.Bytes > quota
|
|
if !over && len(expired) == 0 {
|
|
return report, nil
|
|
}
|
|
candidates := append([]Record(nil), list...)
|
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i].LastSeen.Before(candidates[j].LastSeen) })
|
|
for _, rec := range candidates {
|
|
if ctx.Err() != nil {
|
|
return report, ctx.Err()
|
|
}
|
|
need := expired[rec.ID] || (quota > 0 && report.Bytes > quota)
|
|
if !need {
|
|
continue
|
|
}
|
|
if err := r.deleteIfAllowed(ctx, cap, rec); err != nil {
|
|
report.Blocked = append(report.Blocked, rec.ID)
|
|
continue
|
|
}
|
|
report.Deleted = append(report.Deleted, rec.ID)
|
|
report.Bytes -= rec.Bytes
|
|
if report.Bytes < 0 {
|
|
report.Bytes = 0
|
|
}
|
|
}
|
|
return report, nil
|
|
}
|
|
|
|
// CleanupOne deletes one workspace when its manifest and receipts match.
|
|
func (r *FileRegistry) CleanupOne(ctx context.Context, id string, cap *Capability) error {
|
|
rec, ok, err := r.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return ErrWorkDirNotFound
|
|
}
|
|
if cap == nil {
|
|
cap = NewCapability(r.dataRoot, nil)
|
|
}
|
|
return r.deleteIfAllowed(ctx, cap, rec)
|
|
}
|
|
|
|
func (r *FileRegistry) deleteIfAllowed(ctx context.Context, cap *Capability, rec Record) error {
|
|
if rec.Frozen == nil {
|
|
return ErrCleanupBlocked
|
|
}
|
|
ref := WorkspaceRef{ID: rec.ID, SchemaVersion: SchemaVersion, RunID: rec.RunID}
|
|
if err := cap.Cleanup(ctx, ref, *rec.Frozen, rec.Receipts); err != nil {
|
|
return err
|
|
}
|
|
return r.Delete(rec.ID)
|
|
}
|
|
|
|
// SetRetention stores the frozen manifest and receipts required before deletion.
|
|
func (r *FileRegistry) SetRetention(id string, frozen FrozenManifest, receipts []RetentionReceipt) error {
|
|
rec, ok, err := r.Get(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
rec.ID = id
|
|
}
|
|
rec.Frozen = &frozen
|
|
rec.Receipts = receipts
|
|
return r.Upsert(rec)
|
|
}
|