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.
59func (s *Store) Put(_ context.Context, ref ports.CILogRef, step int32, markdown string) error {
60 if !validRef(ref) || step < 1 || step > domain.CIMaxSteps {
61 return domain.ErrNotFound
62 }
63 if markdown == "" {
64 if err := os.Remove(s.path(ref, step)); err != nil && !errors.Is(err, fs.ErrNotExist) {
65 return fmt.Errorf("remove summary: %w", err)
66 }
67 return nil
68 }
69 dir := s.jobDir(ref)
70 if err := os.MkdirAll(dir, 0o700); err != nil {
71 return fmt.Errorf("create summary directory: %w", err)
72 }
73 f, err := os.CreateTemp(dir, ".tmp-*")
74 if err != nil {
75 return fmt.Errorf("create summary: %w", err)
76 }
77 tmp := f.Name()
78 _, werr := f.WriteString(markdown)
79 serr := f.Sync()
80 cerr := f.Close()
81 if err := errors.Join(werr, serr, cerr); err != nil {
82 _ = os.Remove(tmp)
83 return fmt.Errorf("write summary: %w", err)
84 }
85 if err := os.Rename(tmp, s.path(ref, step)); err != nil {
86 _ = os.Remove(tmp)
87 return fmt.Errorf("store summary: %w", err)
88 }
89 return nil
90}
91
92// List returns the summaries of the steps that have one, by step number.
93func (s *Store) List(_ context.Context, ref ports.CILogRef, limit int, maxBytes int64) ([]domain.CIStepSummary, bool, error) {
94 if !validRef(ref) {
95 return nil, false, nil
96 }
97 entries, err := os.ReadDir(s.jobDir(ref))
98 if errors.Is(err, fs.ErrNotExist) {
99 return nil, false, nil
100 }
101 if err != nil {
102 return nil, false, fmt.Errorf("list summaries: %w", err)
103 }
104 var steps []int32
105 for _, e := range entries {
106 name, ok := strings.CutSuffix(e.Name(), ext)
107 if !ok || e.IsDir() {
108 continue // a temporary file, or something else
109 }
110 n, err := strconv.ParseInt(name, 10, 32)
111 if err != nil || n < 1 || strconv.FormatInt(n, 10) != name {
112 continue
113 }
114 steps = append(steps, int32(n)) //nolint:gosec // parsed with a 32-bit size
115 }
116 slices.Sort(steps)
117 more := limit > 0 && len(steps) > limit
118 if more {
119 steps = steps[:limit]
120 }
121 out := make([]domain.CIStepSummary, 0, len(steps))
122 var read int64
123 exhausted := false
124 for _, n := range steps {
125 if exhausted {
126 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
127 continue
128 }
129 if maxBytes > 0 {
130 // Size the file first: a summary that does not fit is not read.
131 info, err := os.Stat(s.path(ref, n))
132 if errors.Is(err, fs.ErrNotExist) {
133 continue // removed meanwhile
134 }
135 if err != nil {
136 return nil, false, fmt.Errorf("stat summary: %w", err)
137 }
138 if read+info.Size() > maxBytes {
139 exhausted = true
140 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
141 continue
142 }
143 }
144 b, err := readCapped(s.path(ref, n), maxBytes, read)
145 if errors.Is(err, fs.ErrNotExist) {
146 continue // removed meanwhile
147 }
148 if errors.Is(err, errOverBudget) {
149 exhausted = true // replaced by a larger file since the stat
150 out = append(out, domain.CIStepSummary{Step: n, Omitted: true})
151 continue
152 }
153 if err != nil {
154 return nil, false, fmt.Errorf("read summary: %w", err)
155 }
156 read += int64(len(b))
157 out = append(out, domain.CIStepSummary{Step: n, Markdown: string(b)})
158 }
159 return out, more, nil
160}
161
162var errOverBudget = errors.New("over budget")
163
164// readCapped reads a file, at most what is left of maxBytes after read bytes
165// (no cap when maxBytes is 0): a file that is longer than that, because it grew
166// since it was sized, is errOverBudget and its content is dropped.
167func readCapped(path string, maxBytes, read int64) ([]byte, error) {
168 f, err := os.Open(path)
169 if err != nil {
170 return nil, err
171 }
172 defer func() { _ = f.Close() }()
173 if maxBytes <= 0 {
174 return io.ReadAll(f)
175 }
176 left := maxBytes - read
177 b, err := io.ReadAll(io.LimitReader(f, left+1))
178 if err != nil {
179 return nil, err
180 }
181 if int64(len(b)) > left {
182 return nil, errOverBudget
183 }
184 return b, nil
185}
186
187// RemoveJob deletes the summaries of one execution of a job.
188func (s *Store) RemoveJob(_ context.Context, ref ports.CILogRef) error {
189 if !validRef(ref) {
190 return nil
191 }
192 if err := os.RemoveAll(s.jobDir(ref)); err != nil {
193 return fmt.Errorf("remove summaries: %w", err)
194 }
195 return nil
196}
197
198// RemoveRuns deletes the summaries of the runs.
199func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
200 var errs []error
201 for _, id := range runIDs {
202 if id < 1 {
203 continue
204 }
205 if err := os.RemoveAll(filepath.Join(s.root, strconv.FormatInt(id, 10))); err != nil {
206 errs = append(errs, err)
207 }
208 }
209 return errors.Join(errs...)
210}
211
212// RunIDs lists the runs that have a summary directory.
213func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
214 entries, err := os.ReadDir(s.root)
215 if errors.Is(err, fs.ErrNotExist) {
216 return nil, nil
217 }
218 if err != nil {
219 return nil, fmt.Errorf("list summaries: %w", err)
220 }
221 var ids []int64
222 for _, e := range entries {
223 if id, err := strconv.ParseInt(e.Name(), 10, 64); err == nil && e.IsDir() {
224 ids = append(ids, id)
225 }
226 }
227 return ids, nil
228}