1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package cisummaries implements ports.CISummaryStore with one Markdown file per
4// step under $ERMITE_DATA_DIR/ci/summaries/<run id>/<job id>/<attempt>/<step>.md.
5//
6// A summary is written to a temporary file of the same directory, synced, then
7// renamed over the final name: a reader sees the old summary or the new one,
8// never a part. The files of a run go with the logs of the run: the use case
9// that removes the logs removes them too.
10package cisummaries
11
12import (
13 "context"
14 "errors"
15 "fmt"
16 "io"
17 "io/fs"
18 "os"
19 "path/filepath"
20 "slices"
21 "strconv"
22 "strings"
23
24 "bnema.dev/ermite/internal/domain"
25 "bnema.dev/ermite/internal/ports"
26)
27
28// Dir is the directory of the summaries under the data directory.
29func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "summaries") }
30
31const ext = ".md"
32
33// Store is the file-backed summary store.
34type Store struct{ root string }
35
36var _ ports.CISummaryStore = (*Store)(nil)
37
38// New returns a store rooted at dir, which need not exist yet.
39func New(dir string) *Store { return &Store{root: dir} }
40
41// validRef rejects a reference that could escape the directory.
42func validRef(ref ports.CILogRef) bool {
43 if ref.RunID < 1 || ref.Attempt < 1 {
44 return false
45 }
46 _, _, _, ok := domain.ParseJobID(ref.JobID)
47 return ok
48}
49
50func (s *Store) jobDir(ref ports.CILogRef) string {
51 return filepath.Join(s.root, strconv.FormatInt(ref.RunID, 10), ref.JobID, strconv.Itoa(int(ref.Attempt)))
52}
53
54func (s *Store) path(ref ports.CILogRef, step int32) string {
55 return filepath.Join(s.jobDir(ref), strconv.Itoa(int(step))+ext)
56}
57
58// Put stores the summary of a step, or removes it when markdown is empty.
59//
60//hexcheck:ignore suspicious-business-logic-in-adapter validates storage coordinates and atomically writes a step summary, deleting its file when empty
61func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
62 if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
63 return domain.ErrNotFound
64 }
65 if markdown == "" {
66 if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
67 return fmt.Errorf("remove summary: %w", err)
68 }
69 return nil
70 }
71 dir := s.jobDir(ref)
72 if err := os.MkdirAll(dir, 0o700); err != nil {
73 return fmt.Errorf("create summary directory: %w", err)
74 }
75 f, err := os.CreateTemp(dir, ".tmp-*")
76 if err != nil {
77 return fmt.Errorf("create summary: %w", err)
78 }
79 tmp := f.Name()
80 _, werr := f.WriteString(markdown)
81 serr := f.Sync()
82 cerr := f.Close()
83 if err := errors.Join(werr, serr, cerr); err != nil {
84 _ = os.Remove(tmp)
85 return fmt.Errorf("write summary: %w", err)
86 }
87 if err := os.Rename(tmp, s.path(ref, step)); err != nil {
88 _ = os.Remove(tmp)
89 return fmt.Errorf("store summary: %w", err)
90 }
91 return nil
92}
93
94// List returns the summaries of the steps that have one, by step number.
95func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
96 if !validRef(ref) {
97 return nil, false, nil
98 }
99 entries, err := os.ReadDir(s.jobDir(ref))
100 if errors.Is(err, fs.ErrNotExist) {
101 return nil, false, nil
102 }
103 if err != nil {
104 return nil, false, fmt.Errorf("list summaries: %w", err)
105 }
106 var steps []int32
107 for _, e := range entries {
108 name, ok := strings.CutSuffix(e.Name(), ext)
109 if !ok || e.IsDir() {
110 continue // a temporary file, or something else
111 }
112 n, err := strconv.ParseInt(name, 10, 32)
113 if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
114 continue
115 }
116 steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
117 }
118 slices.Sort(steps)
119 more := limit > 0 && len(steps) > limit
120 if more {
121 steps = steps[:limit]
122 }
123 out := make([]domain.CIStepSummary, 0, len(steps))
124 var read int64
125 exhausted := false
126 for _, n := range steps {
127 if exhausted {
128 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
129 continue
130 }
131 if maxBytes > 0 {
132 // Size the file first: a summary that does not fit is not read.
133 info, err := os.Stat(s.path(ref, n))
134 if errors.Is(err, fs.ErrNotExist) {
135 continue // removed meanwhile
136 }
137 if err != nil {
138 return nil, false, fmt.Errorf("stat summary: %w", err)
139 }
140 if read+info.Size() > maxBytes {
141 exhausted = true
142 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
143 continue
144 }
145 }
146 b, err := readCapped(s.path(ref, n), maxBytes, read)
147 if errors.Is(err, fs.ErrNotExist) {
148 continue // removed meanwhile
149 }
150 if errors.Is(err, errOverBudget) {
151 exhausted = true // replaced by a larger file since the stat
152 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
153 continue
154 }
155 if err != nil {
156 return nil, false, fmt.Errorf("read summary: %w", err)
157 }
158 read += int64(len(b))
159 out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
160 }
161 return out, more, nil
162}
163
164var errOverBudget = errors.New("over budget")
165
166// readCapped reads a file, at most what is left of maxBytes after read bytes
167// (no cap when maxBytes is 0): a file that is longer than that, because it grew
168// since it was sized, is errOverBudget and its content is dropped.
169func readCapped(path string, maxBytes, read int64) ([]byte, error) {
170 f, err := os.Open(path)
171 if err != nil {
172 return nil, err
173 }
174 defer func() { _ = f.Close() }()
175 if maxBytes <= 0 {
176 return io.ReadAll(f)
177 }
178 left := maxBytes - read
179 b, err := io.ReadAll(io.LimitReader(f, left+1))
180 if err != nil {
181 return nil, err
182 }
183 if int64(len(b)) > left {
184 return nil, errOverBudget
185 }
186 return b, nil
187}
188
189// RemoveJob deletes the summaries of one execution of a job.
190func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
191 if !validRef(ref) {
192 return nil
193 }
194 if err := os.RemoveAll(s.jobDir(ref)); err != nil {
195 return fmt.Errorf("remove summaries: %w", err)
196 }
197 return nil
198}
199
200// RemoveRuns deletes the summaries of the runs.
201func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
202 var errs []error
203 for _, id := range runIDs {
204 if id < 1 {
205 continue
206 }
207 if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
208 errs = append(errs, err)
209 }
210 }
211 return errors.Join(errs...)
212}
213
214// RunIDs lists the runs that have a summary directory.
215func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
216 entries, err := os.ReadDir(s.root)
217 if errors.Is(err, fs.ErrNotExist) {
218 return nil, nil
219 }
220 if err != nil {
221 return nil, fmt.Errorf("list summaries: %w", err)
222 }
223 var ids []int64
224 for _, e := range entries {
225 if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
226 ids = append(ids, id)
227 }
228 }
229 return ids, nil
230}