1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package ciartifacts reads and removes legacy artifacts recorded before the
4// blob store (ADR 0034), under $ERMITE_DATA_DIR/ci/artifacts. Paths use ids
5// only. The adapter is removed once all legacy artifacts have expired.
6package ciartifacts
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "io"
13 "io/fs"
14 "os"
15 "path/filepath"
16 "slices"
17 "strconv"
18 "strings"
19
20 "bnema.dev/ermite/internal/domain"
21 "bnema.dev/ermite/internal/ports"
22)
23
24// Dir is the directory of the artifacts under the data directory.
25func Dir(dataDir string) string { return filepath.Join(dataDir, "ci", "artifacts") }
26
27const (
28 idFormat = "%020d"
29 blocksSuffix = ".blocks"
30 tmpPrefix = ".tmp-"
31)
32
33// Store is the file-backed artifact store.
34type Store struct{ root string }
35
36var _ ports.CIArtifactFiles = (*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
41func validRef(ref ports.CIArtifactRef) bool { return ref.RunID > 0 && ref.ID > 0 }
42
43func (s *Store) runDir(runID int64) string {
44 return filepath.Join(s.root, fmt.Sprintf(idFormat, runID))
45}
46
47func (s *Store) contentPath(ref ports.CIArtifactRef) string {
48 return filepath.Join(s.runDir(ref.RunID), fmt.Sprintf(idFormat, ref.ID))
49}
50
51func (s *Store) blocksDir(ref ports.CIArtifactRef) string {
52 return s.contentPath(ref) + blocksSuffix
53}
54
55// Open opens the content at offset.
56func (s *Store) Open(_ context.Context, ref ports.CIArtifactRef, offset int64) (io.ReadCloser, error) {
57 if !validRef(ref) || offset < 0 {
58 return nil, domain.ErrNotFound
59 }
60 f, err := os.Open(s.contentPath(ref))
61 if errors.Is(err, fs.ErrNotExist) {
62 return nil, domain.ErrNotFound
63 }
64 if err != nil {
65 return nil, fmt.Errorf("open artifact: %w", err)
66 }
67 if _, err := f.Seek(offset, io.SeekStart); err != nil {
68 _ = f.Close()
69 return nil, fmt.Errorf("seek artifact: %w", err)
70 }
71 return f, nil
72}
73
74// Remove deletes the content, the blocks and any assembly in progress.
75func (s *Store) Remove(_ context.Context, ref ports.CIArtifactRef) error {
76 if !validRef(ref) {
77 return nil
78 }
79 var errs []error
80 if err := os.Remove(s.contentPath(ref)); err != nil && !errors.Is(err, fs.ErrNotExist) {
81 errs = append(errs, err)
82 }
83 errs = append(errs, os.RemoveAll(s.blocksDir(ref)))
84 tmps, _ := filepath.Glob(filepath.Join(s.runDir(ref.RunID), tmpPrefix+fmt.Sprintf(idFormat, ref.ID)+"-*"))
85 for _, t := range tmps {
86 if err := os.Remove(t); err != nil && !errors.Is(err, fs.ErrNotExist) {
87 errs = append(errs, err)
88 }
89 }
90 if err := errors.Join(errs...); err != nil {
91 return fmt.Errorf("remove artifact: %w", err)
92 }
93 return nil
94}
95
96// RemoveRuns deletes the directories of the runs.
97func (s *Store) RemoveRuns(_ context.Context, runIDs []int64) error {
98 var errs []error
99 for _, id := range runIDs {
100 if id < 1 {
101 continue
102 }
103 if err := os.RemoveAll(s.runDir(id)); err != nil {
104 errs = append(errs, err)
105 }
106 }
107 return errors.Join(errs...)
108}
109
110// RunIDs lists the run directories.
111func (s *Store) RunIDs(_ context.Context) ([]int64, error) {
112 entries, err := os.ReadDir(s.root)
113 if errors.Is(err, fs.ErrNotExist) {
114 return nil, nil
115 }
116 if err != nil {
117 return nil, fmt.Errorf("list artifact directories: %w", err)
118 }
119 var ids []int64
120 for _, e := range entries {
121 if id, ok := parseID(e.Name()); ok && e.IsDir() {
122 ids = append(ids, id)
123 }
124 }
125 return ids, nil
126}
127
128// ArtifactIDs lists the artifacts that have a content, blocks or an assembly
129// in progress in the run directory.
130func (s *Store) ArtifactIDs(_ context.Context, runID int64) ([]int64, error) {
131 if runID < 1 {
132 return nil, nil
133 }
134 entries, err := os.ReadDir(s.runDir(runID))
135 if errors.Is(err, fs.ErrNotExist) {
136 return nil, nil
137 }
138 if err != nil {
139 return nil, fmt.Errorf("list artifacts: %w", err)
140 }
141 var ids []int64
142 for _, e := range entries {
143 name := e.Name()
144 if rest, ok := strings.CutPrefix(name, tmpPrefix); ok {
145 name, _, _ = strings.Cut(rest, "-")
146 } else {
147 name = strings.TrimSuffix(name, blocksSuffix)
148 }
149 if id, ok := parseID(name); ok && !slices.Contains(ids, id) {
150 ids = append(ids, id)
151 }
152 }
153 slices.Sort(ids)
154 return ids, nil
155}
156
157// parseID reads a zero-padded id.
158func parseID(name string) (int64, bool) {
159 if len(name) != 20 {
160 return 0, false
161 }
162 id, err := strconv.ParseInt(name, 10, 64)
163 return id, err == nil && id > 0
164}