package web import ( "context" "encoding/json" "errors" "fmt" "io/fs" "log" "net/http" "os" "path/filepath" "github.com/go-chi/chi/v5" "hearthforge/internal/web/views" ) // imageRoutes registers the Images tab. Reads follow REGISTRY_PULL like the // registry itself; deletes are admin-only. func (s *Server) imageRoutes(r chi.Router) { r.Get("/{repo}/images", s.imageList) r.Group(func(r chi.Router) { r.Use(s.requireAdmin) r.Post("/{repo}/images/delete", s.deleteImage) r.Post("/{repo}/images/delete-all", s.deleteAllImages) }) } func (s *Server) imageList(w http.ResponseWriter, r *http.Request) { repo, ok := s.visibleRepo(w, r) if !ok { return } u := User(r) if !s.Cfg.CanPullImages(repo.IsPrivate, u != nil, u != nil && u.IsAdmin) { http.Error(w, "Images are not visible to you", http.StatusForbidden) return } tags, err := s.DB.ListImageTags(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } sizes, err := s.DB.ImageSizes(r.Context(), repo.ID) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } views.Render(w, http.StatusOK, views.ImageList(s.Cfg, u, repo, tags, sizes)) } // deleteImage removes one tag, or the whole image path when no tag is given. // A manifest left without tags goes too, with the layers only it used. func (s *Server) deleteImage(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } image := r.FormValue("image") tag := r.FormValue("tag") if tag == "" { if err := s.purgeImages(r, repo.ID, &image); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/images") return } s.registryMu.Lock() digest, err := s.DB.DeleteTag(r.Context(), repo.ID, image, tag) if err == nil && digest != "" { s.dropUnused(r.Context(), repo.ID, image, []string{digest}, nil) } s.registryMu.Unlock() if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/images") } func (s *Server) deleteAllImages(w http.ResponseWriter, r *http.Request) { repo, ok := s.adminRepo(w, r) if !ok { return } if err := s.purgeImages(r, repo.ID, nil); err != nil { http.Error(w, "Database error", http.StatusInternalServerError) return } redirectTo(w, r, "/"+repo.Name+"/images") } // purgeImages drops one image path, or every image when image is nil, and // removes the files nothing else uses. func (s *Server) purgeImages(r *http.Request, repoID int64, image *string) error { s.registryMu.Lock() defer s.registryMu.Unlock() digests, err := s.DB.RepoDigests(r.Context(), repoID, image) if err != nil { return err } if err := s.DB.DeleteImages(r.Context(), repoID, image); err != nil { return err } for _, d := range digests { s.removeUnreferenced(r.Context(), d) } return nil } // deleteRepoRow removes a repository row and its image files. The cascade // drops the index rows; the files nothing else uses go afterwards. Every // place that deletes a repo goes through here. func (s *Server) deleteRepoRow(r *http.Request, repoID int64) error { s.registryMu.Lock() defer s.registryMu.Unlock() digests, err := s.DB.RepoDigests(r.Context(), repoID, nil) if err != nil { return err } if err := s.DB.DeleteRepo(r.Context(), repoID); err != nil { return err } for _, d := range digests { s.removeUnreferenced(r.Context(), d) } return nil } // dropUnused deletes the given manifests that no tag and no other manifest // of the image use. Then it unlinks the given blobs and the blobs of the // deleted manifests that no remaining manifest uses. Children of a deleted // index go the same way. The caller holds registryMu. Errors are only // logged: the change that triggered the cleanup is already stored, and the // startup sweep removes what is left. // // ponytail: a push in flight that shares a layer only with a dropped // manifest fails its manifest PUT with MANIFEST_BLOB_UNKNOWN. Two CI // pipelines that push the same image path can hit this. A retry works. func (s *Server) dropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) { if err := s.tryDropUnused(context.WithoutCancel(ctx), repoID, image, manifests, blobs); err != nil { log.Printf("registry cleanup of repo %d image %q: %v", repoID, image, err) } } func (s *Server) tryDropUnused(ctx context.Context, repoID int64, image string, manifests, blobs []string) error { for len(manifests) > 0 { used, err := s.imageRefs(ctx, repoID, image) if err != nil { return err } var children []string for _, d := range manifests { if used[d] { continue } // A broken file only leaves its blobs linked until the sweep. refs, _ := s.readManifestRefs(d) gone, err := s.DB.DeleteUntaggedManifest(ctx, repoID, image, d) if err != nil { return err } if !gone { continue } s.removeUnreferenced(ctx, d) blobs = append(blobs, refs.blobs()...) children = append(children, refs.children()...) } manifests = children } if len(blobs) == 0 { return nil } used, err := s.imageRefs(ctx, repoID, image) if err != nil { return err } for _, b := range blobs { if used[b] { continue } if err := s.DB.UnlinkBlob(ctx, repoID, image, b); err != nil { return err } s.removeUnreferenced(ctx, b) } return nil } // imageRefs collects every blob and child manifest digest that the image's // manifests reference. It fails on a manifest it cannot read, because a // missing entry would make a used blob look unused. func (s *Server) imageRefs(ctx context.Context, repoID int64, image string) (map[string]bool, error) { digests, err := s.DB.ManifestDigests(ctx, repoID, image) if err != nil { return nil, err } used := map[string]bool{} for _, d := range digests { refs, err := s.readManifestRefs(d) if err != nil { return nil, fmt.Errorf("manifest %s: %w", d, err) } for _, r := range append(refs.blobs(), refs.children()...) { used[r] = true } } return used, nil } func (s *Server) readManifestRefs(digest string) (manifestRefs, error) { var refs manifestRefs body, err := os.ReadFile(s.blobPath(digest)) if err != nil { return refs, err } return refs, json.Unmarshal(body, &refs) } // SweepRegistry drops untagged manifests and unused blob links of every // image, then deletes blob files that no index row references. It must run // when no push is in flight, because a push links its layers before its // manifest. func (s *Server) SweepRegistry(ctx context.Context) error { s.registryMu.Lock() defer s.registryMu.Unlock() images, err := s.DB.RegistryImages(ctx) if err != nil { return err } for _, img := range images { manifests, err := s.DB.ManifestDigests(ctx, img.RepoID, img.Image) if err != nil { return err } blobs, err := s.DB.BlobDigests(ctx, img.RepoID, img.Image) if err != nil { return err } s.dropUnused(ctx, img.RepoID, img.Image, manifests, blobs) } root, err := os.OpenRoot(filepath.Join(s.Cfg.RegistryDir(), "blobs")) if errors.Is(err, fs.ErrNotExist) { return nil } if err != nil { return err } defer func() { _ = root.Close() }() files := 0 err = fs.WalkDir(root.FS(), ".", func(path string, e fs.DirEntry, err error) error { if err != nil || e.IsDir() { return err } // Keep in sync with blobPath: blobs//. digest := filepath.Base(filepath.Dir(path)) + ":" + e.Name() if used, err := s.DB.DigestReferenced(ctx, digest); err != nil || used { return nil } if root.Remove(path) == nil { files++ } return nil }) if files > 0 { log.Printf("registry sweep: removed %d unused files", files) } return err }