images.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io/fs"
9 "log"
10 "net/http"
11 "os"
12 "path/filepath"
13
14 "github.com/go-chi/chi/v5"
15
16 "hearthforge/internal/web/views"
17)
18
19// imageRoutes registers the Images tab. Reads follow REGISTRY_PULL like the
20// registry itself; deletes are admin-only.
21func (s *Server) imageRoutes(r chi.Router) {
22 r.Get("/{repo}/images", s.imageList)
23 r.Group(func(r chi.Router) {
24 r.Use(s.requireAdmin)
25 r.Post("/{repo}/images/delete", s.deleteImage)
26 r.Post("/{repo}/images/delete-all", s.deleteAllImages)
27 })
28}
29
30func (s *Server) imageList(w http.ResponseWriter, r *http.Request) {
31 repo, ok := s.visibleRepo(w, r)
32 if !ok {
33 return
34 }
35 u := User(r)
36 if !s.Cfg.CanPullImages(repo.IsPrivate, u != nil, u != nil && u.IsAdmin) {
37 http.Error(w, "Images are not visible to you", http.StatusForbidden)
38 return
39 }
40 tags, err := s.DB.ListImageTags(r.Context(), repo.ID)
41 if err != nil {
42 http.Error(w, "Database error", http.StatusInternalServerError)
43 return
44 }
45 sizes, err := s.DB.ImageSizes(r.Context(), repo.ID)
46 if err != nil {
47 http.Error(w, "Database error", http.StatusInternalServerError)
48 return
49 }
50 views.Render(w, http.StatusOK, views.ImageList(s.Cfg, u, repo, tags, sizes))
51}
52
53// deleteImage removes one tag, or the whole image path when no tag is given.
54// A manifest left without tags goes too, with the layers only it used.
55func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) {
56 repo, ok := s.adminRepo(w, r)
57 if !ok {
58 return
59 }
60 image := r.FormValue("image")
61 tag := r.FormValue("tag")
62 if tag == "" {
63 if err := s.purgeImages(r, repo.ID, &image); err != nil {
64 http.Error(w, "Database error", http.StatusInternalServerError)
65 return
66 }
67 redirectTo(w, r, "/"+repo.Name+"/images")
68 return
69 }
70 s.registryMu.Lock()
71 digest, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag)
72 if err == nil && digest != "" {
73 s.dropUnused(r.Context(), repo.ID, image, []string{digest}, nil)
74 }
75 s.registryMu.Unlock()
76 if err != nil {
77 http.Error(w, "Database error", http.StatusInternalServerError)
78 return
79 }
80 redirectTo(w, r, "/"+repo.Name+"/images")
81}
82
83func (s *Server) deleteAllImages(w http.ResponseWriter, r *http.Request) {
84 repo, ok := s.adminRepo(w, r)
85 if !ok {
86 return
87 }
88 if err := s.purgeImages(r, repo.ID, nil); err != nil {
89 http.Error(w, "Database error", http.StatusInternalServerError)
90 return
91 }
92 redirectTo(w, r, "/"+repo.Name+"/images")
93}
94
95// purgeImages drops one image path, or every image when image is nil, and
96// removes the files nothing else uses.
97func (s *Server) purgeImages(r *http.Request, repoID int64, image *string) error {
98 s.registryMu.Lock()
99 defer s.registryMu.Unlock()
100 digests, err := s.DB.RepoDigests(r.Context(), repoID, image)
101 if err != nil {
102 return err
103 }
104 if err := s.DB.DeleteImages(r.Context(), repoID, image); err != nil {
105 return err
106 }
107 for _, d := range digests {
108 s.removeUnreferenced(r.Context(), d)
109 }
110 return nil
111}
112
113// deleteRepoRow removes a repository row and its image files. The cascade
114// drops the index rows; the files nothing else uses go afterwards. Every
115// place that deletes a repo goes through here.
116func (s *Server) deleteRepoRow(r *http.Request, repoID int64) error {
117 s.registryMu.Lock()
118 defer s.registryMu.Unlock()
119 digests, err := s.DB.RepoDigests(r.Context(), repoID, nil)
120 if err != nil {
121 return err
122 }
123 if err := s.DB.DeleteRepo(r.Context(), repoID); err != nil {
124 return err
125 }
126 for _, d := range digests {
127 s.removeUnreferenced(r.Context(), d)
128 }
129 return nil
130}
131
132// dropUnused deletes the given manifests that no tag and no other manifest
133// of the image use. Then it unlinks the given blobs and the blobs of the
134// deleted manifests that no remaining manifest uses. Children of a deleted
135// index go the same way. The caller holds registryMu. Errors are only
136// logged: the change that triggered the cleanup is already stored, and the
137// startup sweep removes what is left.
138//
139// ponytail: a push in flight that shares a layer only with a dropped
140// manifest fails its manifest PUT with MANIFEST_BLOB_UNKNOWN. Two CI
141// pipelines that push the same image path can hit this. A retry works.
142func (s *Server) dropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) {
143 if err := s.tryDropUnused(context.WithoutCancel(ctx), repoID, image, manifests, blobs); err != nil {
144 log.Printf("registry cleanup of repo %d image %q: %v", repoID, image, err)
145 }
146}
147
148func (s *Server) tryDropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) error {
149 for len(manifests) > 0 {
150 used, err := s.imageRefs(ctx, repoID, image)
151 if err != nil {
152 return err
153 }
154 var children []string
155 for _, d := range manifests {
156 if used[d] {
157 continue
158 }
159 // A broken file only leaves its blobs linked until the sweep.
160 refs, _ := s.readManifestRefs(d)
161 gone, err := s.DB.DeleteUntaggedManifest(ctx, repoID, image, d)
162 if err != nil {
163 return err
164 }
165 if !gone {
166 continue
167 }
168 s.removeUnreferenced(ctx, d)
169 blobs = append(blobs, refs.blobs()...)
170 children = append(children, refs.children()...)
171 }
172 manifests = children
173 }
174 if len(blobs) == 0 {
175 return nil
176 }
177 used, err := s.imageRefs(ctx, repoID, image)
178 if err != nil {
179 return err
180 }
181 for _, b := range blobs {
182 if used[b] {
183 continue
184 }
185 if err := s.DB.UnlinkBlob(ctx, repoID, image, b); err != nil {
186 return err
187 }
188 s.removeUnreferenced(ctx, b)
189 }
190 return nil
191}
192
193// imageRefs collects every blob and child manifest digest that the image's
194// manifests reference. It fails on a manifest it cannot read, because a
195// missing entry would make a used blob look unused.
196func (s *Server) imageRefs(ctx context.Context, repoID int64, image string) (map[string]bool, error) {
197 digests, err := s.DB.ManifestDigests(ctx, repoID, image)
198 if err != nil {
199 return nil, err
200 }
201 used := map[string]bool{}
202 for _, d := range digests {
203 refs, err := s.readManifestRefs(d)
204 if err != nil {
205 return nil, fmt.Errorf("manifest %s: %w", d, err)
206 }
207 for _, r := range append(refs.blobs(), refs.children()...) {
208 used[r] = true
209 }
210 }
211 return used, nil
212}
213
214func (s *Server) readManifestRefs(digest string) (manifestRefs, error) {
215 var refs manifestRefs
216 body, err := os.ReadFile(s.blobPath(digest))
217 if err != nil {
218 return refs, err
219 }
220 return refs, json.Unmarshal(body, &refs)
221}
222
223// SweepRegistry drops untagged manifests and unused blob links of every
224// image, then deletes blob files that no index row references. It must run
225// when no push is in flight, because a push links its layers before its
226// manifest.
227func (s *Server) SweepRegistry(ctx context.Context) error {
228 s.registryMu.Lock()
229 defer s.registryMu.Unlock()
230 images, err := s.DB.RegistryImages(ctx)
231 if err != nil {
232 return err
233 }
234 for _, img := range images {
235 manifests, err := s.DB.ManifestDigests(ctx, img.RepoID, img.Image)
236 if err != nil {
237 return err
238 }
239 blobs, err := s.DB.BlobDigests(ctx, img.RepoID, img.Image)
240 if err != nil {
241 return err
242 }
243 s.dropUnused(ctx, img.RepoID, img.Image, manifests, blobs)
244 }
245 root, err := os.OpenRoot(filepath.Join(s.Cfg.RegistryDir(), "blobs"))
246 if errors.Is(err, fs.ErrNotExist) {
247 return nil
248 }
249 if err != nil {
250 return err
251 }
252 defer func() { _ = root.Close() }()
253 files := 0
254 err = fs.WalkDir(root.FS(), ".", func(path string, e fs.DirEntry, err error) error {
255 if err != nil || e.IsDir() {
256 return err
257 }
258 // Keep in sync with blobPath: blobs/<algorithm>/<hex>.
259 digest := filepath.Base(filepath.Dir(path)) + ":" + e.Name()
260 if used, err := s.DB.DigestReferenced(ctx, digest); err != nil || used {
261 return nil
262 }
263 if root.Remove(path) == nil {
264 files++
265 }
266 return nil
267 })
268 if files > 0 {
269 log.Printf("registry sweep: removed %d unused files", files)
270 }
271 return err
272}
273