Files
silo-server/internal/diagnostics/cleanup.go
Quick104andClaude Fable 5 dee46f9398 fix(diagnostics): address round-5 review findings on PR #445
- service: reject supplied child-profile attribution with a distinct
  ErrChildProfileForbidden (403 child_profile_forbidden) instead of
  silently dropping it as if the profile were not found; a profile that
  is simply not the user's still drops attribution unchanged
- repo: add a manifest-free list projection (reportListSelectSQL /
  scanReportSummary) for admin list and retention/stale cleanup queries
  so they no longer drag the full manifest JSONB per row; keep the full
  projection for GetByID/DeleteByID and mark Manifest omitempty
- cleanup: delete/mark the DB row before the blob in retention and stale
  loops so a mid-run DB failure can't leave a ready report pointing at a
  missing bundle; blob-delete failures are logged with bucket/keys for
  orphan cleanup to reap rather than aborting the run (shared helper with
  the admin DeleteReport path)
- admin: reject diagnostics settings where max_bytes_per_user would fall
  below max_bundle_bytes (and the reciprocal), which would make every
  max-size upload fail quota
- router/demo: route POST /diagnostics/reports through DemoGuard and block
  the reports prefix in demo mode while keeping GET /diagnostics/status
  available

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012e3QjbPo96ed9Mn2qRiUkh
2026-07-21 13:36:08 -04:00

258 lines
7.8 KiB
Go

package diagnostics
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
)
const (
ObjectPrefix = diagnosticObjectPrefix + "/"
DefaultReceivingGrace = time.Hour
)
type CleanupRepository interface {
DeleteByID(ctx context.Context, id string) (*Report, error)
MarkFailed(ctx context.Context, id string) error
RetentionCandidates(ctx context.Context, olderThan time.Time, perUserByteCap int64) ([]Report, error)
StaleReceiving(ctx context.Context, grace time.Duration) ([]Report, error)
LiveBlobKeys(ctx context.Context, keys []string) (map[string]ReportState, error)
}
type CleanupOptions struct {
Now func() time.Time
StaleReceivingGrace time.Duration
Logger *slog.Logger
}
type CleanupResult struct {
RetentionReportsDeleted int `json:"retention_reports_deleted"`
StaleReportsDeleted int `json:"stale_reports_deleted"`
OrphanObjectsDeleted int `json:"orphan_objects_deleted"`
}
func (r CleanupResult) ReportsDeleted() int {
return r.RetentionReportsDeleted + r.StaleReportsDeleted
}
func CleanupOnce(
ctx context.Context,
repo CleanupRepository,
settingsStore SettingsStore,
store ObjectStore,
logger *slog.Logger,
) (CleanupResult, error) {
settings, err := LoadSettings(ctx, settingsStore)
if err != nil {
return CleanupResult{}, fmt.Errorf("load diagnostics cleanup settings: %w", err)
}
return CleanupReports(ctx, repo, store, settings, CleanupOptions{Logger: logger})
}
func CleanupReports(
ctx context.Context,
repo CleanupRepository,
store ObjectStore,
settings Settings,
opts CleanupOptions,
) (CleanupResult, error) {
if repo == nil {
return CleanupResult{}, nil
}
now := func() time.Time { return time.Now().UTC() }
if opts.Now != nil {
now = opts.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
grace := opts.StaleReceivingGrace
if grace <= 0 {
grace = DefaultReceivingGrace
}
cutoff := time.Time{}
if settings.RetentionDays > 0 {
cutoff = now().Add(-time.Duration(settings.RetentionDays) * 24 * time.Hour)
}
// A single poisoned report (e.g. a permission error on one S3 object) must
// not abort the whole run, or every scheduled pass re-hits it first and
// blocks retention, stale reconciliation, and orphan cleanup indefinitely.
// Log-and-continue per report; aggregate per-item failures and still surface
// genuine query/iteration errors.
var result CleanupResult
var errs []error
candidates, err := repo.RetentionCandidates(ctx, cutoff, settings.MaxBytesPerUser)
if err != nil {
errs = append(errs, fmt.Errorf("load diagnostic retention candidates: %w", err))
return result, errors.Join(errs...)
}
for _, report := range candidates {
// Row first: a DB failure after the object is gone would leave a visible
// ready report whose bundle 404s on download. If the blob delete then
// fails the row is already gone, so log it for the orphan pass below to
// reap rather than aborting the run (see the admin DeleteReport path).
if _, err := repo.DeleteByID(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("delete diagnostic retention row %s: %w", report.ID, err))
continue
}
if err := deleteReportObjects(ctx, store, &report, logger); err != nil {
logDeferredBlobDeletion(ctx, logger, store, &report, err)
}
result.RetentionReportsDeleted++
}
stale, err := repo.StaleReceiving(ctx, grace)
if err != nil {
errs = append(errs, fmt.Errorf("load stale diagnostic reports: %w", err))
return result, errors.Join(errs...)
}
for _, report := range stale {
// Same rows-first ordering as retention: mark/delete the row before the
// blob so a mid-cleanup DB failure never leaves a row pointing at a
// missing object, and let orphan cleanup reap a blob that fails to delete.
if report.State == StateReceiving {
if err := repo.MarkFailed(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("mark stale diagnostic report failed %s: %w", report.ID, err))
continue
}
}
if _, err := repo.DeleteByID(ctx, report.ID); err != nil && !IsReportNotFound(err) {
errs = append(errs, fmt.Errorf("delete stale diagnostic row %s: %w", report.ID, err))
continue
}
if err := deleteReportObjects(ctx, store, &report, logger); err != nil {
logDeferredBlobDeletion(ctx, logger, store, &report, err)
}
result.StaleReportsDeleted++
}
if store == nil || strings.TrimSpace(store.Bucket()) == "" {
return result, errors.Join(errs...)
}
keys, err := store.ListObjects(ctx, ObjectPrefix)
if err != nil {
errs = append(errs, fmt.Errorf("list diagnostic objects: %w", err))
return result, errors.Join(errs...)
}
if len(keys) == 0 {
return result, errors.Join(errs...)
}
liveKeys, err := repo.LiveBlobKeys(ctx, keys)
if err != nil {
errs = append(errs, fmt.Errorf("load live diagnostic blob keys: %w", err))
return result, errors.Join(errs...)
}
for _, key := range keys {
if _, ok := liveKeys[key]; ok {
continue
}
if err := deleteObjectIfPresent(ctx, store, store.Bucket(), key); err != nil {
errs = append(errs, fmt.Errorf("delete orphan diagnostic object %s: %w", key, err))
continue
}
logger.InfoContext(ctx, "diagnostic orphan object deleted",
"component", "diagnostics",
"key", key,
)
result.OrphanObjectsDeleted++
}
return result, errors.Join(errs...)
}
func deleteReportObjects(ctx context.Context, store ObjectStore, report *Report, logger *slog.Logger) error {
if report == nil {
return nil
}
keys := reportObjectKeys(*report)
if store == nil || strings.TrimSpace(store.Bucket()) == "" {
if len(keys) == 0 {
return nil
}
logSkippedObjectDeletion(ctx, logger, report.ID, keys)
return nil
}
bucket := stringValue(report.BlobBucket)
if bucket == "" {
bucket = store.Bucket()
}
for _, key := range keys {
if err := deleteObjectIfPresent(ctx, store, bucket, key); err != nil {
return err
}
}
return nil
}
func logSkippedObjectDeletion(ctx context.Context, logger *slog.Logger, reportID string, keys []string) {
if logger == nil {
logger = slog.Default()
}
logger.WarnContext(ctx, "diagnostic report blob deletion skipped because storage is unavailable",
"component", "diagnostics",
"report_id", reportID,
"keys", keys,
)
}
// logDeferredBlobDeletion records a report blob that could not be deleted after
// its DB row was already removed. The row is the source of truth, so the object
// is now an orphan for the orphan-cleanup pass (this run or a later one) to
// reap; we log the bucket and keys rather than fail so one unreachable object
// can't block row deletion. Shared by the retention/stale loops and the admin
// DeleteReport path.
func logDeferredBlobDeletion(ctx context.Context, logger *slog.Logger, store ObjectStore, report *Report, err error) {
if logger == nil {
logger = slog.Default()
}
logger.ErrorContext(ctx, "diagnostic report blob deletion failed after row deletion",
"component", "diagnostics",
"report_id", report.ID,
"bucket", reportBlobBucket(report, store),
"keys", reportObjectKeys(*report),
"error", err,
)
}
func deleteObjectIfPresent(ctx context.Context, store ObjectStore, bucket, key string) error {
key = strings.TrimSpace(key)
if key == "" {
return nil
}
if err := store.DeleteObject(ctx, bucket, key); err != nil && !IsObjectNotFound(err) {
return err
}
return nil
}
func reportObjectKeys(report Report) []string {
keys := make([]string, 0, 2)
if key := stringValue(report.BlobKey); key != "" {
keys = append(keys, key)
}
if report.ID != "" && report.UserID > 0 {
deterministic := reportObjectKey(report.UserID, report.ID)
seen := false
for _, key := range keys {
if key == deterministic {
seen = true
break
}
}
if !seen {
keys = append(keys, deterministic)
}
}
return keys
}
func IsReportNotFound(err error) bool {
return errors.Is(err, ErrNotFound)
}