mirror of
https://github.com/Quiq/docker-registry-ui.git
synced 2026-07-17 19:59:56 +00:00
497 lines
15 KiB
Go
497 lines
15 KiB
Go
package registry
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/authn"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
const userAgent = "registry-ui"
|
|
|
|
// JobInfo stores the last run time and duration of a background job.
|
|
type JobInfo struct {
|
|
LastRun time.Time
|
|
Duration time.Duration
|
|
}
|
|
|
|
// Client main class.
|
|
type Client struct {
|
|
puller *remote.Puller
|
|
pusher *remote.Pusher
|
|
logger *logrus.Entry
|
|
repos []string
|
|
tagsCache map[string][]string
|
|
tagsCacheMux sync.RWMutex
|
|
isCatalogReady bool
|
|
nameOptions []name.Option
|
|
catalogJobInfo JobInfo
|
|
tagsJobInfo JobInfo
|
|
}
|
|
|
|
type ImageInfo struct {
|
|
IsImageIndex bool
|
|
IsImage bool
|
|
ImageRefRepo string
|
|
ImageRefTag string
|
|
ImageRefDigest string
|
|
MediaType string
|
|
Platforms string
|
|
Manifest map[string]interface{}
|
|
|
|
// Image specific
|
|
ImageSize int64
|
|
Created time.Time
|
|
ConfigImageID string
|
|
ConfigFile map[string]interface{}
|
|
}
|
|
|
|
// NewClient initialize Client.
|
|
func NewClient() *Client {
|
|
var authOpt remote.Option
|
|
if viper.GetBool("registry.auth_with_keychain") {
|
|
authOpt = remote.WithAuthFromKeychain(authn.DefaultKeychain)
|
|
} else {
|
|
password := viper.GetString("registry.password")
|
|
if password == "" {
|
|
passwdFile := viper.GetString("registry.password_file")
|
|
if _, err := os.Stat(passwdFile); os.IsNotExist(err) {
|
|
panic(err)
|
|
}
|
|
data, err := os.ReadFile(passwdFile)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
password = strings.TrimSuffix(string(data[:]), "\n")
|
|
}
|
|
|
|
authOpt = remote.WithAuth(authn.FromConfig(authn.AuthConfig{
|
|
Username: viper.GetString("registry.username"), Password: password,
|
|
}))
|
|
}
|
|
|
|
pageSize := viper.GetInt("performance.catalog_page_size")
|
|
puller, _ := remote.NewPuller(authOpt, remote.WithUserAgent(userAgent), remote.WithPageSize(pageSize))
|
|
pusher, _ := remote.NewPusher(authOpt, remote.WithUserAgent(userAgent))
|
|
|
|
insecure := viper.GetBool("registry.insecure")
|
|
nameOptions := []name.Option{}
|
|
if insecure {
|
|
nameOptions = append(nameOptions, name.Insecure)
|
|
}
|
|
|
|
c := &Client{
|
|
puller: puller,
|
|
pusher: pusher,
|
|
logger: SetupLogging("registry.client"),
|
|
repos: []string{},
|
|
tagsCache: map[string][]string{},
|
|
nameOptions: nameOptions,
|
|
}
|
|
return c
|
|
}
|
|
|
|
func (c *Client) StartBackgroundJobs() {
|
|
catalogInterval := viper.GetInt("performance.catalog_refresh_interval")
|
|
tagsRefreshInterval := viper.GetInt("performance.tags_async_refresh_interval")
|
|
isStarted := false
|
|
for {
|
|
c.RefreshCatalog()
|
|
if !isStarted && tagsRefreshInterval > 0 {
|
|
// Start after the first catalog refresh
|
|
go c.RefreshTags(tagsRefreshInterval)
|
|
isStarted = true
|
|
}
|
|
if catalogInterval == 0 {
|
|
c.logger.Warn("Catalog refresh is disabled in the config and will not run anymore.")
|
|
break
|
|
}
|
|
time.Sleep(time.Duration(catalogInterval) * time.Minute)
|
|
}
|
|
|
|
}
|
|
|
|
func (c *Client) RefreshCatalog() {
|
|
ctx := context.Background()
|
|
start := time.Now()
|
|
c.logger.Info("[RefreshCatalog] Started reading catalog...")
|
|
registry, _ := name.NewRegistry(viper.GetString("registry.hostname"), c.nameOptions...)
|
|
cat, err := c.puller.Catalogger(ctx, registry)
|
|
if err != nil {
|
|
c.logger.Errorf("[RefreshCatalog] Error fetching catalog: %s", err)
|
|
if !c.isCatalogReady {
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
repos := []string{}
|
|
// The library itself does retries under the hood.
|
|
for cat.HasNext() {
|
|
data, err := cat.Next(ctx)
|
|
if err != nil {
|
|
c.logger.Errorf("[RefreshCatalog] Error listing catalog: %s", err)
|
|
}
|
|
if data != nil {
|
|
repos = append(repos, data.Repos...)
|
|
if !c.isCatalogReady {
|
|
c.repos = append(c.repos, data.Repos...)
|
|
c.logger.Debug("[RefreshCatalog] Repo batch received:", data.Repos)
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(repos) > 0 {
|
|
c.repos = repos
|
|
} else {
|
|
c.logger.Warn("[RefreshCatalog] Catalog looks empty, preserving previous list if any.")
|
|
}
|
|
c.logger.Debugf("[RefreshCatalog] Catalog: %s", c.repos)
|
|
c.catalogJobInfo = JobInfo{LastRun: time.Now(), Duration: time.Since(start)}
|
|
c.logger.Infof("[RefreshCatalog] Job complete (%v): %d repos found", c.catalogJobInfo.Duration, len(c.repos))
|
|
c.isCatalogReady = true
|
|
}
|
|
|
|
// IsCatalogReady whether catalog is ready for the first time use
|
|
func (c *Client) IsCatalogReady() bool {
|
|
return c.isCatalogReady
|
|
}
|
|
|
|
// GetRepos get all repos
|
|
func (c *Client) GetRepos() []string {
|
|
return c.repos
|
|
}
|
|
|
|
// GetJobInfo returns the last run info for background jobs.
|
|
func (c *Client) GetJobInfo() (catalog, tags JobInfo) {
|
|
return c.catalogJobInfo, c.tagsJobInfo
|
|
}
|
|
|
|
// ListTags get tags for the repo, returns tags and whether they were cached.
|
|
// If online refresh is enabled and tag count is below threshold, fetches fresh tags.
|
|
// If background refresh is disabled, always fetches online regardless of max count.
|
|
func (c *Client) ListTags(repoName string) ([]string, bool) {
|
|
c.tagsCacheMux.RLock()
|
|
cachedTags, exists := c.tagsCache[repoName]
|
|
tagCount := len(cachedTags)
|
|
c.tagsCacheMux.RUnlock()
|
|
|
|
maxCount := viper.GetInt("performance.tags_online_refresh_max_count")
|
|
asyncRefreshInterval := viper.GetInt("performance.tags_async_refresh_interval")
|
|
|
|
// If background refresh is disabled, always refresh online.
|
|
// If online refresh is enabled and repo is cached and below threshold, refresh online.
|
|
if asyncRefreshInterval == 0 || (maxCount > 0 && exists && tagCount <= maxCount) {
|
|
if tags := c.FetchAndCacheTagsForRepo(repoName); tags != nil {
|
|
return tags, true
|
|
}
|
|
return []string{}, false
|
|
}
|
|
|
|
// Return from cache if exists
|
|
if exists {
|
|
return cachedTags, true
|
|
}
|
|
|
|
// Return empty array if not cached yet
|
|
return []string{}, false
|
|
}
|
|
|
|
// FetchAndCacheTagsForRepo fetch and cache tags for a specific repo
|
|
func (c *Client) FetchAndCacheTagsForRepo(repoName string) []string {
|
|
ctx := context.Background()
|
|
|
|
repo, err := name.NewRepository(viper.GetString("registry.hostname")+"/"+repoName, c.nameOptions...)
|
|
if err != nil {
|
|
c.logger.Errorf("Error creating repo reference for %s: %s", repoName, err)
|
|
return nil
|
|
}
|
|
|
|
tags, err := c.puller.List(ctx, repo)
|
|
if err != nil {
|
|
c.logger.Errorf("Error listing tags for repo %s: %s", repoName, err)
|
|
return nil
|
|
}
|
|
|
|
// Update cache
|
|
c.tagsCacheMux.Lock()
|
|
c.tagsCache[repoName] = tags
|
|
c.tagsCacheMux.Unlock()
|
|
|
|
c.logger.Debugf("Cached %d tags for repo %s", len(tags), repoName)
|
|
return tags
|
|
}
|
|
|
|
// GetImageInfo get image info by the reference - tag name or digest sha256.
|
|
func (c *Client) GetImageInfo(imageRef string) (ImageInfo, error) {
|
|
ctx := context.Background()
|
|
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
|
|
if err != nil {
|
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
|
return ImageInfo{}, err
|
|
}
|
|
descr, err := c.puller.Get(ctx, ref)
|
|
if err != nil {
|
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
|
return ImageInfo{}, err
|
|
}
|
|
|
|
ii := ImageInfo{
|
|
ImageRefRepo: ref.Context().RepositoryStr(),
|
|
ImageRefTag: ref.Identifier(),
|
|
ImageRefDigest: descr.Digest.String(),
|
|
MediaType: string(descr.MediaType),
|
|
}
|
|
if descr.MediaType.IsIndex() {
|
|
ii.IsImageIndex = true
|
|
} else if descr.MediaType.IsImage() {
|
|
ii.IsImage = true
|
|
} else {
|
|
c.logger.Errorf("Image reference %s is neither Index nor Image", imageRef)
|
|
return ImageInfo{}, err
|
|
}
|
|
|
|
// Performance notes:
|
|
// puller.Get() → 1 API call (fetches manifest)
|
|
// descr.Image() → 0 API calls (type conversion)
|
|
// img.ConfigFile() → 1 API call (fetches config blob)
|
|
// img.Manifest() → 0 API calls (already fetched on puller.Get())
|
|
//
|
|
if ii.IsImage {
|
|
img, err := descr.Image()
|
|
if err != nil {
|
|
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
|
|
return ImageInfo{}, err
|
|
}
|
|
cfg, err := img.ConfigFile()
|
|
if err != nil {
|
|
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
|
|
return ImageInfo{}, err
|
|
}
|
|
ii.Created = cfg.Created.Time
|
|
if cfg.Created.Time.IsZero() {
|
|
ii.Created = extractCreatedFromAnnotations(img)
|
|
}
|
|
ii.Platforms = getPlatform(cfg.Platform())
|
|
ii.ConfigFile = structToMap(cfg)
|
|
// ImageID is what is shown in the terminal when doing "docker images".
|
|
// This is a config sha256 of the corresponding image manifest (single platform).
|
|
if x, _ := img.ConfigName(); len(x.String()) > 19 {
|
|
ii.ConfigImageID = x.String()[7:19]
|
|
}
|
|
mf, _ := img.Manifest()
|
|
for _, l := range mf.Layers {
|
|
ii.ImageSize += l.Size
|
|
}
|
|
ii.Manifest = structToMap(mf)
|
|
} else if ii.IsImageIndex {
|
|
// In case of Image Index, if we request for Image() > ConfigFile(), it will be resolved
|
|
// to a config of one of the manifests (one of the platforms).
|
|
// It doesn't make a lot of sense, even they are usually identical. Also extra API calls which slows things down.
|
|
imgIdx, err := descr.ImageIndex()
|
|
if err != nil {
|
|
c.logger.Errorf("Cannot convert descriptor to ImageIndex for image reference %s: %s", imageRef, err)
|
|
return ImageInfo{}, err
|
|
}
|
|
IdxMf, _ := imgIdx.IndexManifest()
|
|
platforms := []string{}
|
|
for _, m := range IdxMf.Manifests {
|
|
platforms = append(platforms, getPlatform(m.Platform))
|
|
}
|
|
ii.Platforms = strings.Join(UniqueSortedSlice(platforms), ", ")
|
|
ii.Manifest = structToMap(IdxMf)
|
|
}
|
|
|
|
return ii, nil
|
|
}
|
|
|
|
func getPlatform(p *v1.Platform) string {
|
|
if p != nil {
|
|
return p.String()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// structToMap convert struct to map so it can be formatted as HTML table easily
|
|
func structToMap(obj interface{}) map[string]interface{} {
|
|
var res map[string]interface{}
|
|
jsonBytes, _ := json.Marshal(obj)
|
|
json.Unmarshal(jsonBytes, &res)
|
|
return res
|
|
}
|
|
|
|
// GetImageCreated get image created time
|
|
func (c *Client) GetImageCreated(imageRef string) time.Time {
|
|
zeroTime := new(time.Time)
|
|
ctx := context.Background()
|
|
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
|
|
if err != nil {
|
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
|
return *zeroTime
|
|
}
|
|
descr, err := c.puller.Get(ctx, ref)
|
|
if err != nil {
|
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
|
return *zeroTime
|
|
}
|
|
// In case of ImageIndex, it is resolved to a random sub-image which should be fine.
|
|
img, err := descr.Image()
|
|
if err != nil {
|
|
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
|
|
return *zeroTime
|
|
}
|
|
cfg, err := img.ConfigFile()
|
|
if err != nil {
|
|
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
|
|
return *zeroTime
|
|
}
|
|
if cfg.Created.Time.IsZero() {
|
|
return extractCreatedFromAnnotations(img)
|
|
}
|
|
|
|
return cfg.Created.Time
|
|
}
|
|
|
|
func extractCreatedFromAnnotations(img v1.Image) time.Time {
|
|
// Some tools (e.g. cosign without --record-creation-timestamp) produce images
|
|
// with a zero creation time in the config file. As a fallback, try to extract
|
|
// the creation date from the manifest annotation "org.opencontainers.image.created".
|
|
// See https://specs.opencontainers.org/image-spec/annotations/
|
|
zeroTime := new(time.Time)
|
|
if mf, err := img.Manifest(); err == nil && mf.Annotations != nil {
|
|
if createdStr, ok := mf.Annotations["org.opencontainers.image.created"]; ok {
|
|
if createdTime, err := time.Parse(time.RFC3339, createdStr); err == nil {
|
|
return createdTime
|
|
}
|
|
}
|
|
}
|
|
return *zeroTime
|
|
}
|
|
|
|
// GetTotalTagCount returns the total number of tags across all cached repos.
|
|
func (c *Client) GetTotalTagCount() int {
|
|
c.tagsCacheMux.RLock()
|
|
defer c.tagsCacheMux.RUnlock()
|
|
|
|
total := 0
|
|
for _, tags := range c.tagsCache {
|
|
total += len(tags)
|
|
}
|
|
return total
|
|
}
|
|
|
|
// RepoTagCount represents a repo and its tag count.
|
|
type RepoTagCount struct {
|
|
Repo string
|
|
Count int
|
|
}
|
|
|
|
// GetTopReposByTagCount returns the top N repos sorted by tag count descending.
|
|
func (c *Client) GetTopReposByTagCount(n int) []RepoTagCount {
|
|
c.tagsCacheMux.RLock()
|
|
result := make([]RepoTagCount, 0, len(c.tagsCache))
|
|
for repo, tags := range c.tagsCache {
|
|
result = append(result, RepoTagCount{Repo: repo, Count: len(tags)})
|
|
}
|
|
c.tagsCacheMux.RUnlock()
|
|
|
|
sort.Slice(result, func(i, j int) bool {
|
|
return result[i].Count > result[j].Count
|
|
})
|
|
|
|
if len(result) > n {
|
|
result = result[:n]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// SubRepoTagCounts return map with tag counts according to the provided list of repos/sub-repos etc.
|
|
func (c *Client) SubRepoTagCounts(repoPath string, repos []string) map[string]int {
|
|
counts := map[string]int{}
|
|
for _, r := range repos {
|
|
subRepo := r
|
|
if repoPath != "" {
|
|
subRepo = repoPath + "/" + r
|
|
}
|
|
|
|
// Acquire lock to prevent concurrent map iteration and map write.
|
|
c.tagsCacheMux.RLock()
|
|
for k, v := range c.tagsCache {
|
|
if k == subRepo || strings.HasPrefix(k, subRepo+"/") {
|
|
counts[subRepo] = counts[subRepo] + len(v)
|
|
}
|
|
}
|
|
c.tagsCacheMux.RUnlock()
|
|
}
|
|
return counts
|
|
}
|
|
|
|
// RefreshTags fetch repository tags in background periodically and cache them.
|
|
func (c *Client) RefreshTags(interval int) {
|
|
for {
|
|
start := time.Now()
|
|
c.logger.Info("[RefreshTags] Started caching tags for all repositories...")
|
|
for _, r := range c.repos {
|
|
c.FetchAndCacheTagsForRepo(r)
|
|
}
|
|
c.tagsJobInfo = JobInfo{LastRun: time.Now(), Duration: time.Since(start)}
|
|
c.logger.Infof("[RefreshTags] Job complete (%v).", c.tagsJobInfo.Duration)
|
|
time.Sleep(time.Duration(interval) * time.Minute)
|
|
}
|
|
}
|
|
|
|
// DeleteTag delete image tag.
|
|
func (c *Client) DeleteTag(repoPath, tag string) {
|
|
ctx := context.Background()
|
|
imageRef := repoPath + ":" + tag
|
|
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
|
|
if err != nil {
|
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
|
return
|
|
}
|
|
// Get manifest so we have a digest to delete by
|
|
descr, err := c.puller.Get(ctx, ref)
|
|
if err != nil {
|
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
|
return
|
|
}
|
|
// Parse image reference by digest now
|
|
imageRefDigest := ref.Context().RepositoryStr() + "@" + descr.Digest.String()
|
|
ref, err = name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRefDigest, c.nameOptions...)
|
|
if err != nil {
|
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRefDigest, err)
|
|
return
|
|
}
|
|
|
|
// Delete tag using digest.
|
|
// Note, it will also delete any other tags pointing to the same digest!
|
|
err = c.pusher.Delete(ctx, ref)
|
|
if err != nil {
|
|
c.logger.Errorf("Error deleting image %s: %s", imageRef, err)
|
|
return
|
|
}
|
|
// Remove tag from cache
|
|
c.tagsCacheMux.Lock()
|
|
if cachedTags, exists := c.tagsCache[repoPath]; exists {
|
|
updatedTags := []string{}
|
|
for _, t := range cachedTags {
|
|
if t != tag {
|
|
updatedTags = append(updatedTags, t)
|
|
}
|
|
}
|
|
c.tagsCache[repoPath] = updatedTags
|
|
}
|
|
c.tagsCacheMux.Unlock()
|
|
|
|
c.logger.Infof("Image %s has been successfully deleted.", imageRef)
|
|
}
|