1// Package volumes implements volume management operations.
2package volumes
3
4import (
5 "context"
6 "fmt"
7
8 "github.com/bnema/zerowrap"
9
10 "github.com/bnema/gordon/internal/boundaries/out"
11 "github.com/bnema/gordon/internal/domain"
12 "github.com/bnema/gordon/internal/usecase/pruneguard"
13)
14
15// Service implements the VolumeService interface.
16type Service struct {
17 runtime out.ContainerRuntime
18 // protection, pruneRuntime, and barrier are the prune ports. All
19 // three are required for PruneVolumes; a missing port fails closed.
20 protection out.PruneProtectionStore
21 pruneRuntime out.PruneRuntime
22 barrier out.GCBarrier
23}
24
25// NewService creates a new volume service.
26func NewService(runtime out.ContainerRuntime) *Service {
27 return &Service{runtime: runtime}
28}
29
30// WithPrunePorts wires the prune ports: the coherent protection store,
31// the runtime inventory/deletion port, and the GC barrier.
32func (s *Service) WithPrunePorts(protection out.PruneProtectionStore, pruneRuntime out.PruneRuntime, barrier out.GCBarrier) *Service {
33 s.protection = protection
34 s.pruneRuntime = pruneRuntime
35 s.barrier = barrier
36 return s
37}
38
39// ListVolumes returns all volumes with usage status.
40func (s *Service) ListVolumes(ctx context.Context) ([]*domain.VolumeInfo, error) {
41 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
42 zerowrap.FieldLayer: "usecase",
43 zerowrap.FieldUseCase: "ListVolumes",
44 })
45
46 vols, err := s.runtime.ListVolumes(ctx)
47 if err != nil {
48 return nil, fmt.Errorf("failed to list volumes: %w", err)
49 }
50 return vols, nil
51}
52
53// PruneVolumes plans (and unless dryRun executes) one volume prune run.
54//
55// A volume is removed only when a durable ownership record explicitly
56// records it as released, the runtime labels agree with that record, and
57// no container uses it. Every other volume is protected or unknown.
58// A valid plan with zero eligible volumes succeeds: with current
59// metadata a zero-deletion volume prune is a normal outcome.
60func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.VolumePruneReport, []*domain.VolumeInfo, error) {
61 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
62 zerowrap.FieldLayer: "usecase",
63 zerowrap.FieldUseCase: "PruneVolumes",
64 "dry_run": dryRun,
65 })
66 log := zerowrap.FromCtx(ctx)
67
68 lease, err := s.acquireExclusive(ctx)
69 if err != nil {
70 return nil, nil, fmt.Errorf("volumes: acquire prune lease: %w", err)
71 }
72 defer lease.Release()
73
74 if s.protection == nil || s.pruneRuntime == nil {
75 return nil, nil, fmt.Errorf("volumes: prune requires a protection store and a runtime inventory port: %w", domain.ErrPruneDisabled)
76 }
77
78 snapshot, err := s.protection.ProtectionSnapshot(ctx)
79 if err != nil {
80 return nil, nil, fmt.Errorf("volumes: read protection snapshot: %w", err)
81 }
82
83 inventory, err := s.pruneRuntime.InventoryRuntime(ctx)
84 if err != nil {
85 return nil, nil, fmt.Errorf("volumes: read runtime inventory: %w", err)
86 }
87
88 plan := &domain.PrunePlan{
89 Gaps: append(append([]domain.InventoryGap(nil), snapshot.Gaps...), inventory.Gaps...),
90 Volumes: pruneguard.PlanVolumes(pruneguard.VolumePlanInput{
91 Volumes: inventory.Volumes,
92 Snapshot: snapshot,
93 Complete: inventory.Complete(),
94 }),
95 }
96 if err := plan.Validate(); err != nil {
97 return nil, nil, fmt.Errorf("volumes: refusing to execute an invalid prune plan: %w", err)
98 }
99
100 report := &domain.VolumePruneReport{Plan: *domain.NewPruneReport(plan, !dryRun)}
101 if dryRun {
102 return report, nil, nil
103 }
104
105 byName := make(map[string]*domain.VolumeInfo, len(inventory.Volumes))
106 for _, volume := range inventory.Volumes {
107 if volume != nil {
108 byName[volume.Name] = volume
109 }
110 }
111
112 var removed []*domain.VolumeInfo
113 for _, ref := range plan.EligibleVolumes() {
114 if err := s.pruneRuntime.RemoveVolumeExact(ctx, ref); err != nil {
115 log.Warn().Err(err).Str("volume", ref.Name).Msg("failed to remove volume, skipping")
116 report.Plan.Failures = append(report.Plan.Failures, domain.PruneFailure{
117 Kind: domain.PruneResourceVolume, Ref: ref.Name, Err: err.Error(),
118 })
119 continue
120 }
121 report.Plan.Deleted = append(report.Plan.Deleted, domain.PruneCandidateReport{
122 Kind: domain.PruneResourceVolume, Ref: ref.Name, Verdict: domain.PruneVerdictEligible,
123 })
124 report.VolumesRemoved++
125 if volume, ok := byName[ref.Name]; ok {
126 report.SpaceReclaimed += volume.Size
127 report.Plan.ReclaimedBytes += volume.Size
128 // The driver reported this size for the volume it removed.
129 report.Plan.ReclaimedKnown = true
130 removed = append(removed, volume)
131 continue
132 }
133 removed = append(removed, &domain.VolumeInfo{Name: ref.Name})
134 }
135
136 return report, removed, nil
137}
138
139// acquireExclusive takes the GC exclusive lease for the whole prune run,
140// or a no-op lease when no barrier is wired.
141func (s *Service) acquireExclusive(ctx context.Context) (out.GCLease, error) {
142 if s.barrier == nil {
143 return noopGCLease{}, nil
144 }
145 return s.barrier.AcquireExclusive(ctx)
146}
147
148// noopGCLease is the lease used when no barrier is wired.
149type noopGCLease struct{}
150
151func (noopGCLease) Release() {}