Files
silo-server/internal/diagnostics/admin.go
T
Quick104andClaude Fable 5 a6348b3dc5 fix(diagnostics): address PR #445 review findings
- bundle: reject tar entry names that differ from their trimmed form instead
  of normalizing padded names into the allowlist
- repo: reserve expected bytes on receiving rows and count receiving+ready in
  the per-user byte quota so concurrent/multi-node uploads can't overshoot
- contract: require the crash object for event report types and keep it absent
  for manual; add contract tests
- settings/service: seed diagnostics.server_instance_id atomically via
  insert-if-absent and adopt the winning value across nodes
- bundle/service: capture the embedded manifest.json during ValidateBundle and
  reject reports whose embedded manifest disagrees with the part-1 manifest
  (minus archive); add tests
- admin: delete the DB row before the blob on DeleteReport; log bucket/key when
  the blob delete fails instead of leaving a visible report with a missing bundle
- bundle: reject PAX/GNU tar formats and extension records that smuggle bytes
  past validation; add a PAX-archive rejection test
- migration: add CHECK constraints for state, report_type, and platform
- docs: add text/jsonc language identifiers to the two unfenced code blocks
- cleanup: log-and-continue per report and aggregate errors so one poisoned
  report no longer blocks the whole run; update tests
- tasks: give diagnostics its own cleanup interval key instead of reusing the
  opslog key, and bound the startup settings lookup

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

128 lines
3.4 KiB
Go

package diagnostics
import (
"context"
"errors"
"fmt"
"io"
"strings"
"time"
)
const ReportDownloadContentType = BundleContentType
var ErrReportNotReady = errors.New("diagnostic report is not ready")
func (s *Service) ListForAdmin(ctx context.Context, filters ListFilters) (ListResult, error) {
if s.reports == nil {
return ListResult{}, ErrReportStoreUnavailable
}
return s.reports.ListForAdmin(ctx, filters)
}
func (s *Service) GetReport(ctx context.Context, id string) (*Report, error) {
if s.reports == nil {
return nil, ErrReportStoreUnavailable
}
return s.reports.GetByID(ctx, id)
}
func (s *Service) PresignReportDownload(ctx context.Context, report *Report, expiry time.Duration) (string, error) {
bucket, key, err := s.readyReportBlobLocation(report)
if err != nil {
return "", err
}
if expiry <= 0 {
expiry = 15 * time.Minute
}
return s.store.PresignGetURL(ctx, bucket, key, expiry)
}
func (s *Service) EffectiveReportDownloadTTL(requested time.Duration) time.Duration {
if requested <= 0 {
return requested
}
effectiveStore, ok := s.store.(interface {
EffectivePresignTTL(time.Duration) time.Duration
})
if !ok {
return requested
}
effective := effectiveStore.EffectivePresignTTL(requested)
if effective <= 0 {
return requested
}
return effective
}
func (s *Service) OpenReportDownload(ctx context.Context, report *Report) (io.ReadCloser, error) {
bucket, key, err := s.readyReportBlobLocation(report)
if err != nil {
return nil, err
}
return s.store.GetObject(ctx, bucket, key)
}
func (s *Service) DeleteReport(ctx context.Context, id string) (*Report, error) {
if s.reports == nil {
return nil, ErrReportStoreUnavailable
}
// Delete the row before the blob: a DB failure after the object is gone
// would otherwise leave a visible report whose bundle can no longer be
// downloaded. DeleteByID returns the deleted row (and ErrNotFound when
// absent), so the blob location is captured from it. If the blob delete
// fails the row is already gone, so log the bucket/key for an operator (and
// the orphan reconciler) to reap instead of failing the delete.
deleted, err := s.reports.DeleteByID(ctx, id)
if err != nil {
return nil, err
}
if err := deleteReportObjects(ctx, s.store, deleted, s.logger); err != nil {
s.logger.ErrorContext(ctx, "diagnostic report blob deletion failed after row deletion",
"component", "diagnostics",
"report_id", deleted.ID,
"bucket", reportBlobBucket(deleted, s.store),
"keys", reportObjectKeys(*deleted),
"error", err,
)
}
return deleted, nil
}
func reportBlobBucket(report *Report, store ObjectStore) string {
bucket := stringValue(report.BlobBucket)
if bucket == "" && store != nil {
bucket = store.Bucket()
}
return bucket
}
func (s *Service) readyReportBlobLocation(report *Report) (string, string, error) {
if report == nil {
return "", "", ErrNotFound
}
if report.State != StateReady {
return "", "", ErrReportNotReady
}
if s.store == nil || strings.TrimSpace(s.store.Bucket()) == "" {
return "", "", ErrStorageUnavailable
}
bucket := stringValue(report.BlobBucket)
if bucket == "" {
bucket = s.store.Bucket()
}
key := stringValue(report.BlobKey)
if key == "" {
return "", "", fmt.Errorf("%w: diagnostic report has no blob key", ErrStorageUnavailable)
}
return bucket, key, nil
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}