Implement purge_tags_keep_from_file option. Refactoring. (#63)

* Implement purge_tags_keep_from_file option.
* main.go refactoring.
* Refactor user permissions.
* Update changelog.
This commit is contained in:
Roman Vynar 2022-09-02 18:05:45 +03:00 committed by GitHub
parent b5e11aae10
commit e538d4c3f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 404 additions and 328 deletions

5
.gitignore vendored
View File

@ -1,3 +1,4 @@
config-dev.yml
data/registry_events.db data/registry_events.db
vendor/ config-dev.yml
dev.Makefile
keep_tags.json

View File

@ -1,5 +1,16 @@
## Changelog ## Changelog
### 0.9.5 (unreleased)
* Upgrade Go version to 1.19.0, alpine to 3.16 and other dependencies.
* Add an option `anyone_can_view_events` to restrict access to the event log. Set it to `true` to make event log accessible to anyone (to restore the previous behaviour), otherwise the default `false` will hide it and only admins can view it (thanks to @ribbybibby).
* Add an option `purge_tags_keep_regexp` to preserve tags based on regexp (thanks to @dmaes).
* Add an option `purge_tags_keep_from_file` to preserve tags for repos listed in the file provided.
* When purging tags sort them by name reversibly when no date available, e.g. for OCI image format (thanks to @dmaes).
* Fix a bug when there was a bit more tags preserved than defined by `purge_tags_keep_count`.
Also see `config.yml` in this repo for the description of new options.
### 0.9.4 (2022-04-06) ### 0.9.4 (2022-04-06)
* Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies. * Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies.

View File

@ -102,8 +102,6 @@ You can try to run in dry-run mode first to see what is going to be purged:
Alternatively, you can schedule the purging task with built-in cron feature: Alternatively, you can schedule the purging task with built-in cron feature:
purge_tags_keep_days: 90
purge_tags_keep_count: 2
purge_tags_schedule: '0 10 3 * * *' purge_tags_schedule: '0 10 3 * * *'
Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron

84
config.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"io/ioutil"
"net/url"
"os"
"strings"
"github.com/quiq/docker-registry-ui/registry"
"gopkg.in/yaml.v2"
)
type configData struct {
ListenAddr string `yaml:"listen_addr"`
BasePath string `yaml:"base_path"`
RegistryURL string `yaml:"registry_url"`
VerifyTLS bool `yaml:"verify_tls"`
Username string `yaml:"registry_username"`
Password string `yaml:"registry_password"`
PasswordFile string `yaml:"registry_password_file"`
EventListenerToken string `yaml:"event_listener_token"`
EventRetentionDays int `yaml:"event_retention_days"`
EventDatabaseDriver string `yaml:"event_database_driver"`
EventDatabaseLocation string `yaml:"event_database_location"`
EventDeletionEnabled bool `yaml:"event_deletion_enabled"`
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
AnyoneCanDelete bool `yaml:"anyone_can_delete"`
AnyoneCanViewEvents bool `yaml:"anyone_can_view_events"`
Admins []string `yaml:"admins"`
Debug bool `yaml:"debug"`
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
PurgeTagsKeepRegexp string `yaml:"purge_tags_keep_regexp"`
PurgeTagsKeepFromFile string `yaml:"purge_tags_keep_from_file"`
PurgeTagsSchedule string `yaml:"purge_tags_schedule"`
PurgeConfig *registry.PurgeTagsConfig
}
func readConfig(configFile string) *configData {
var config configData
// Read config file.
if _, err := os.Stat(configFile); os.IsNotExist(err) {
panic(err)
}
data, err := ioutil.ReadFile(configFile)
if err != nil {
panic(err)
}
if err := yaml.Unmarshal(data, &config); err != nil {
panic(err)
}
// Validate registry URL.
if _, err := url.Parse(config.RegistryURL); err != nil {
panic(err)
}
// Normalize base path.
config.BasePath = strings.Trim(config.BasePath, "/")
if config.BasePath != "" {
config.BasePath = "/" + config.BasePath
}
// Read password from file.
if config.PasswordFile != "" {
if _, err := os.Stat(config.PasswordFile); os.IsNotExist(err) {
panic(err)
}
data, err := ioutil.ReadFile(config.PasswordFile)
if err != nil {
panic(err)
}
config.Password = strings.TrimSuffix(string(data[:]), "\n")
}
config.PurgeConfig = &registry.PurgeTagsConfig{
KeepDays: config.PurgeTagsKeepDays,
KeepMinCount: config.PurgeTagsKeepCount,
KeepTagRegexp: config.PurgeTagsKeepRegexp,
KeepFromFile: config.PurgeTagsKeepFromFile,
}
return &config
}

View File

@ -32,13 +32,15 @@ event_database_location: data/registry_events.db
# You can disable event deletion on some hosts when you are running docker-registry on master-master or # You can disable event deletion on some hosts when you are running docker-registry on master-master or
# cluster setup to avoid deadlocks or replication break. # cluster setup to avoid deadlocks or replication break.
event_deletion_enabled: True event_deletion_enabled: true
# Cache refresh interval in minutes. # Cache refresh interval in minutes.
# How long to cache repository list and tag counts. # How long to cache repository list and tag counts.
cache_refresh_interval: 10 cache_refresh_interval: 10
# If users can delete tags. If set to False, then only admins listed below. # If all users can view the event log. If set to false, then only admins listed below.
anyone_can_view_events: true
# If all users can delete tags. If set to false, then only admins listed below.
anyone_can_delete: false anyone_can_delete: false
# Users allowed to delete tags. # Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy. # This should be sent via X-WEBAUTH-USER header from your proxy.
@ -50,8 +52,16 @@ debug: true
# How many days to keep tags but also keep the minimal count provided no matter how old. # How many days to keep tags but also keep the minimal count provided no matter how old.
purge_tags_keep_days: 90 purge_tags_keep_days: 90
purge_tags_keep_count: 2 purge_tags_keep_count: 2
# Keep tags matching regexp no matter how old
# purge_tags_keep_regexp: '^latest$' # Keep tags matching regexp no matter how old, e.g. '^latest$'
# Empty string disables this feature.
purge_tags_keep_regexp: ''
# Keep tags listed in the file no matter how old.
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
# Empty string disables this feature.
purge_tags_keep_from_file: ''
# Enable built-in cron to schedule purging tags in server mode. # Enable built-in cron to schedule purging tags in server mode.
# Empty string disables this feature. # Empty string disables this feature.
# Example: '25 54 17 * * *' will run it at 17:54:25 daily. # Example: '25 54 17 * * *' will run it at 17:54:25 daily.

304
main.go
View File

@ -3,55 +3,20 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"io/ioutil"
"net/http"
"net/url" "net/url"
"os"
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/quiq/docker-registry-ui/events" "github.com/quiq/docker-registry-ui/events"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/docker-registry-ui/registry"
"github.com/robfig/cron" "github.com/robfig/cron"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"gopkg.in/yaml.v2"
) )
type configData struct {
ListenAddr string `yaml:"listen_addr"`
BasePath string `yaml:"base_path"`
RegistryURL string `yaml:"registry_url"`
VerifyTLS bool `yaml:"verify_tls"`
Username string `yaml:"registry_username"`
Password string `yaml:"registry_password"`
PasswordFile string `yaml:"registry_password_file"`
EventListenerToken string `yaml:"event_listener_token"`
EventRetentionDays int `yaml:"event_retention_days"`
EventDatabaseDriver string `yaml:"event_database_driver"`
EventDatabaseLocation string `yaml:"event_database_location"`
EventDeletionEnabled bool `yaml:"event_deletion_enabled"`
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
AnyoneCanDelete bool `yaml:"anyone_can_delete"`
AnyoneCanViewEvents bool `yaml:"anyone_can_view_events"`
Admins []string `yaml:"admins"`
Debug bool `yaml:"debug"`
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
PurgeTagsKeepRegexp string `yaml:"purge_tags_keep_regexp"`
PurgeTagsSchedule string `yaml:"purge_tags_schedule"`
}
type template struct {
View *jet.Set
}
type apiClient struct { type apiClient struct {
client *registry.Client client *registry.Client
eventListener *events.EventListener eventListener *events.EventListener
config configData config *configData
} }
func main() { func main() {
@ -67,48 +32,16 @@ func main() {
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything") flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
flag.Parse() flag.Parse()
// Setup logging
if loggingLevel != "info" { if loggingLevel != "info" {
if level, err := logrus.ParseLevel(loggingLevel); err == nil { if level, err := logrus.ParseLevel(loggingLevel); err == nil {
logrus.SetLevel(level) logrus.SetLevel(level)
} }
} }
// Read config file. // Read config file
if _, err := os.Stat(configFile); os.IsNotExist(err) { a.config = readConfig(configFile)
panic(err) a.config.PurgeConfig.DryRun = purgeDryRun
}
bytes, err := ioutil.ReadFile(configFile)
if err != nil {
panic(err)
}
if err := yaml.Unmarshal(bytes, &a.config); err != nil {
panic(err)
}
// Validate registry URL.
u, err := url.Parse(a.config.RegistryURL)
if err != nil {
panic(err)
}
// Normalize base path.
if a.config.BasePath != "" {
if !strings.HasPrefix(a.config.BasePath, "/") {
a.config.BasePath = "/" + a.config.BasePath
}
if strings.HasSuffix(a.config.BasePath, "/") {
a.config.BasePath = a.config.BasePath[0 : len(a.config.BasePath)-1]
}
}
// Read password from file.
if a.config.PasswordFile != "" {
if _, err := os.Stat(a.config.PasswordFile); os.IsNotExist(err) {
panic(err)
}
passwordBytes, err := ioutil.ReadFile(a.config.PasswordFile)
if err != nil {
panic(err)
}
a.config.Password = strings.TrimSuffix(string(passwordBytes[:]), "\n")
}
// Init registry API client. // Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password) a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
@ -116,19 +49,21 @@ func main() {
panic(fmt.Errorf("cannot initialize api client or unsupported auth method")) panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
} }
purgeFunc := func() {
registry.PurgeOldTags(a.client, a.config.PurgeConfig)
}
// Execute CLI task and exit. // Execute CLI task and exit.
if purgeTags { if purgeTags {
a.purgeOldTags(purgeDryRun) purgeFunc()
return return
} }
// Schedules to purge tags. // Schedules to purge tags.
if a.config.PurgeTagsSchedule != "" { if a.config.PurgeTagsSchedule != "" {
c := cron.New() c := cron.New()
task := func() { if err := c.AddFunc(a.config.PurgeTagsSchedule, purgeFunc); err != nil {
a.purgeOldTags(purgeDryRun) panic(fmt.Errorf("invalid schedule format: %s", a.config.PurgeTagsSchedule))
}
if err := c.AddFunc(a.config.PurgeTagsSchedule, task); err != nil {
panic(fmt.Errorf("Invalid schedule format: %s", a.config.PurgeTagsSchedule))
} }
c.Start() c.Start()
} }
@ -145,7 +80,8 @@ func main() {
// Template engine init. // Template engine init.
e := echo.New() e := echo.New()
e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath) registryHost, _ := url.Parse(a.config.RegistryURL) // validated already in config.go
e.Renderer = setupRenderer(a.config.Debug, registryHost.Host, a.config.BasePath)
// Web routes. // Web routes.
e.File("/favicon.ico", "static/favicon.ico") e.File("/favicon.ico", "static/favicon.ico")
@ -171,213 +107,3 @@ func main() {
e.Logger.Fatal(e.Start(a.config.ListenAddr)) e.Logger.Fatal(e.Start(a.config.ListenAddr))
} }
func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace")
if namespace == "" {
namespace = "library"
}
repos, _ := a.client.Repositories(true)[namespace]
data := a.dataWithPermissions(c)
data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces())
data.Set("repos", repos)
data.Set("tagCounts", a.client.TagCounts())
return c.Render(http.StatusOK, "repositories.html", data)
}
func (a *apiClient) viewTags(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := a.client.Tags(repoPath)
data := a.dataWithPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tags", tags)
repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data)
}
func (a *apiClient) viewTagInfo(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// Retrieve full image info from various versions of manifests
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
sha256list, manifests := a.client.ManifestList(repoPath, tag)
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
isDigest := strings.HasPrefix(tag, "sha256:")
if len(manifests) > 0 {
sha256 = sha256list
}
// Gather layers v2
var layersV2 []map[string]gjson.Result
for _, s := range gjson.Get(infoV2, "layers").Array() {
layersV2 = append(layersV2, s.Map())
}
// Gather layers v1
var layersV1 []map[string]interface{}
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
// Sort key in the map to show the ordered on UI.
m["ordered_keys"] = registry.SortedMapKeys(m)
layersV1 = append(layersV1, m)
}
// Count image size
var imageSize int64
if gjson.Get(infoV2, "layers").Exists() {
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
imageSize = imageSize + s.Int()
}
} else {
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
}
}
// Count layers
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
// Gather sub-image info of multi-arch or cache image
var digestList []map[string]interface{}
for _, s := range manifests {
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
// Sub-image of the specific arch.
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
var dSize int64
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
dSize = dSize + d.Int()
}
r["size"] = dSize
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
}
} else {
// Sub-image of the cache type.
r["size"] = s.Get("size").Int()
}
r["ordered_keys"] = registry.SortedMapKeys(r)
digestList = append(digestList, r)
}
// Populate template vars
data := a.dataWithPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tag", tag)
data.Set("repoPath", repoPath)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
data.Set("created", created)
data.Set("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
data.Set("isDigest", isDigest)
data.Set("digestList", digestList)
return c.Render(http.StatusOK, "tag_info.html", data)
}
func (a *apiClient) deleteTag(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
if a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) {
a.client.DeleteTag(repoPath, tag)
}
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
// dataWithPermissions returns a jet.VarMap with permission related information
// set
func (a *apiClient) dataWithPermissions(c echo.Context) jet.VarMap {
user := c.Request().Header.Get("X-WEBAUTH-USER")
data := jet.VarMap{}
data.Set("user", user)
data.Set("deleteAllowed", a.checkDeletePermission(user))
data.Set("eventsAllowed", a.checkEventsPermission(user))
return data
}
// checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users.
func (a *apiClient) checkDeletePermission(user string) bool {
deleteAllowed := a.config.AnyoneCanDelete
if !deleteAllowed {
for _, u := range a.config.Admins {
if u == user {
deleteAllowed = true
break
}
}
}
return deleteAllowed
}
// checkEventsPermission checks if anyone is allowed to view events or only
// admins
func (a *apiClient) checkEventsPermission(user string) bool {
eventsAllowed := a.config.AnyoneCanViewEvents
if !eventsAllowed {
for _, u := range a.config.Admins {
if u == user {
eventsAllowed = true
break
}
}
}
return eventsAllowed
}
// viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error {
data := a.dataWithPermissions(c)
data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data)
}
// receiveEvents receive events.
func (a *apiClient) receiveEvents(c echo.Context) error {
a.eventListener.ProcessEvents(c.Request())
return c.String(http.StatusOK, "OK")
}
// purgeOldTags purges old tags.
func (a *apiClient) purgeOldTags(dryRun bool) {
registry.PurgeOldTags(a.client, dryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount, a.config.PurgeTagsKeepRegexp)
}

View File

@ -1,12 +1,32 @@
package registry package registry
import ( import (
"math"
"testing" "testing"
"time" "time"
"github.com/smartystreets/goconvey/convey" "github.com/smartystreets/goconvey/convey"
) )
func TestKeepMinCount(t *testing.T) {
keepTags := []string{"1.8.15"}
purgeTags := []string{"1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5", "1.8.4", "1.8.3"}
purgeTagsKeepCount := 10
// Keep minimal count of tags no matter how old they are.
if len(keepTags) < purgeTagsKeepCount {
// Min of threshold-keep but not more than purge.
takeFromPurge := int(math.Min(float64(purgeTagsKeepCount-len(keepTags)), float64(len(purgeTags))))
keepTags = append(keepTags, purgeTags[:takeFromPurge]...)
purgeTags = purgeTags[takeFromPurge:]
}
convey.Convey("Test keep min count logic", t, func() {
convey.So(keepTags, convey.ShouldResemble, []string{"1.8.15", "1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5"})
convey.So(purgeTags, convey.ShouldResemble, []string{"1.8.4", "1.8.3"})
})
}
func TestSortedMapKeys(t *testing.T) { func TestSortedMapKeys(t *testing.T) {
a := map[string]string{ a := map[string]string{
"foo": "bar", "foo": "bar",

View File

@ -2,6 +2,9 @@ package registry
import ( import (
"fmt" "fmt"
"io/ioutil"
"math"
"os"
"regexp" "regexp"
"sort" "sort"
"time" "time"
@ -9,6 +12,14 @@ import (
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
type PurgeTagsConfig struct {
DryRun bool
KeepDays int
KeepMinCount int
KeepTagRegexp string
KeepFromFile string
}
type tagData struct { type tagData struct {
name string name string
created time.Time created time.Time
@ -25,11 +36,12 @@ func (p timeSlice) Len() int {
} }
func (p timeSlice) Less(i, j int) bool { func (p timeSlice) Less(i, j int) bool {
// reverse sort tags on name if equal dates (OCI image case)
// see https://github.com/Quiq/docker-registry-ui/pull/62
if p[i].created.Equal(p[j].created) { if p[i].created.Equal(p[j].created) {
return p[i].name > p[j].name return p[i].name > p[j].name
} else {
return p[i].created.After(p[j].created)
} }
return p[i].created.After(p[j].created)
} }
func (p timeSlice) Swap(i, j int) { func (p timeSlice) Swap(i, j int) {
@ -37,10 +49,27 @@ func (p timeSlice) Swap(i, j int) {
} }
// PurgeOldTags purge old tags. // PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int, purgeTagsKeepRegexp string) { func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
logger := SetupLogging("registry.tasks.PurgeOldTags") logger := SetupLogging("registry.tasks.PurgeOldTags")
var keepTagsFromFile gjson.Result
if config.KeepFromFile != "" {
if _, err := os.Stat(config.KeepFromFile); os.IsNotExist(err) {
logger.Warnf("Cannot open %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
data, err := ioutil.ReadFile(config.KeepFromFile)
if err != nil {
logger.Warnf("Cannot read %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
keepTagsFromFile = gjson.ParseBytes(data)
}
dryRunText := "" dryRunText := ""
if purgeDryRun { if config.DryRun {
logger.Warn("Dry-run mode enabled.") logger.Warn("Dry-run mode enabled.")
dryRunText = "skipped" dryRunText = "skipped"
} }
@ -58,10 +87,10 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
} }
tags := client.Tags(repo) tags := client.Tags(repo)
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
if len(tags) == 0 { if len(tags) == 0 {
continue continue
} }
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
for _, tag := range tags { for _, tag := range tags {
_, infoV1, _ := client.TagInfo(repo, tag, true) _, infoV1, _ := client.TagInfo(repo, tag, true)
if infoV1 == "" { if infoV1 == "" {
@ -75,27 +104,35 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
} }
logger.Infof("Scanned %d repositories.", count) logger.Infof("Scanned %d repositories.", count)
logger.Info("Filtering out tags for purging...") logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", config.KeepDays, config.KeepMinCount)
if config.KeepTagRegexp != "" {
logger.Infof("Keeping tags matching regexp: %s", config.KeepTagRegexp)
}
if config.KeepFromFile != "" {
logger.Infof("Keeping tags for repos from the file: %+v", keepTagsFromFile)
}
purgeTags := map[string][]string{} purgeTags := map[string][]string{}
keepTags := map[string][]string{} keepTags := map[string][]string{}
count = 0 count = 0
for _, repo := range SortedMapKeys(repos) { for _, repo := range SortedMapKeys(repos) {
// Sort tags by "created" from newest to oldest. // Sort tags by "created" from newest to oldest.
sortedTags := make(timeSlice, 0, len(repos[repo])) sort.Sort(repos[repo])
for _, d := range repos[repo] {
sortedTags = append(sortedTags, d)
}
sort.Sort(sortedTags)
repos[repo] = sortedTags
// Filter out tags by retention days and regexp // Prep the list of tags to preserve if defined in the file
for _, tag := range repos[repo] { tagsFromFile := []string{}
regexpKeep := false for _, i := range keepTagsFromFile.Get(repo).Array() {
if purgeTagsKeepRegexp != "" { tagsFromFile = append(tagsFromFile, i.String())
regexpKeep, _ = regexp.MatchString(purgeTagsKeepRegexp, tag.name)
} }
delta := int(now.Sub(tag.created).Hours() / 24)
if !regexpKeep && delta > purgeTagsKeepDays { // Filter out tags
for _, tag := range repos[repo] {
daysOld := int(now.Sub(tag.created).Hours() / 24)
keepByRegexp := false
if config.KeepTagRegexp != "" {
keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name)
}
if daysOld > config.KeepDays && !keepByRegexp && !ItemInSlice(tag.name, tagsFromFile) {
purgeTags[repo] = append(purgeTags[repo], tag.name) purgeTags[repo] = append(purgeTags[repo], tag.name)
} else { } else {
keepTags[repo] = append(keepTags[repo], tag.name) keepTags[repo] = append(keepTags[repo], tag.name)
@ -103,14 +140,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
} }
// Keep minimal count of tags no matter how old they are. // Keep minimal count of tags no matter how old they are.
if len(repos[repo])-len(purgeTags[repo]) < purgeTagsKeepCount { if len(keepTags[repo]) < config.KeepMinCount {
if len(purgeTags[repo]) > purgeTagsKeepCount { // At least "threshold"-"keep" but not more than available for "purge".
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:purgeTagsKeepCount]...) takeFromPurge := int(math.Min(float64(config.KeepMinCount-len(keepTags[repo])), float64(len(purgeTags[repo]))))
purgeTags[repo] = purgeTags[repo][purgeTagsKeepCount:] keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...)
} else { purgeTags[repo] = purgeTags[repo][takeFromPurge:]
keepTags[repo] = append(keepTags[repo], purgeTags[repo]...)
delete(purgeTags, repo)
}
} }
count = count + len(purgeTags[repo]) count = count + len(purgeTags[repo])
@ -125,8 +159,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
} }
for _, repo := range SortedMapKeys(purgeTags) { for _, repo := range SortedMapKeys(purgeTags) {
if len(purgeTags[repo]) == 0 {
continue
}
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText) logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
if purgeDryRun { if config.DryRun {
continue continue
} }
for _, tag := range purgeTags[repo] { for _, tag := range purgeTags[repo] {

View File

@ -21,7 +21,7 @@ type Template struct {
func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
t, err := r.View.GetTemplate(name) t, err := r.View.GetTemplate(name)
if err != nil { if err != nil {
panic(fmt.Errorf("Fatal error template file: %s", err)) panic(fmt.Errorf("fatal error template file: %s", err))
} }
vars, ok := data.(jet.VarMap) vars, ok := data.(jet.VarMap)
if !ok { if !ok {
@ -29,7 +29,7 @@ func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
} }
err = t.Execute(w, vars, nil) err = t.Execute(w, vars, nil)
if err != nil { if err != nil {
panic(fmt.Errorf("Error rendering template %s: %s", name, err)) panic(fmt.Errorf("error rendering template %s: %s", name, err))
} }
return nil return nil
} }

View File

@ -25,7 +25,7 @@
<div style="padding: 10px 0; margin-bottom: 20px"> <div style="padding: 10px 0; margin-bottom: 20px">
<div style="text-align: center; color:darkgrey"> <div style="text-align: center; color:darkgrey">
Docker Registry UI v{{version}} &copy; 2017-2022 <a href="https://quiq.com">Quiq Inc.</a> Docker Registry UI v{{version}} &copy; by <a href="https://quiq.com">Quiq Inc.</a>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,3 +1,3 @@
package main package main
const version = "0.9.4" const version = "0.9.5"

189
web.go Normal file
View File

@ -0,0 +1,189 @@
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
)
const usernameHTTPHeader = "X-WEBAUTH-USER"
func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap {
user := c.Request().Header.Get(usernameHTTPHeader)
data := jet.VarMap{}
data.Set("user", user)
data.Set("eventsAllowed", a.config.AnyoneCanViewEvents || registry.ItemInSlice(user, a.config.Admins))
data.Set("deleteAllowed", a.config.AnyoneCanDelete || registry.ItemInSlice(user, a.config.Admins))
return data
}
func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace")
if namespace == "" {
namespace = "library"
}
repos := a.client.Repositories(true)[namespace]
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces())
data.Set("repos", repos)
data.Set("tagCounts", a.client.TagCounts())
return c.Render(http.StatusOK, "repositories.html", data)
}
func (a *apiClient) viewTags(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := a.client.Tags(repoPath)
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tags", tags)
repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data)
}
func (a *apiClient) viewTagInfo(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// Retrieve full image info from various versions of manifests
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
sha256list, manifests := a.client.ManifestList(repoPath, tag)
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
isDigest := strings.HasPrefix(tag, "sha256:")
if len(manifests) > 0 {
sha256 = sha256list
}
// Gather layers v2
var layersV2 []map[string]gjson.Result
for _, s := range gjson.Get(infoV2, "layers").Array() {
layersV2 = append(layersV2, s.Map())
}
// Gather layers v1
var layersV1 []map[string]interface{}
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
// Sort key in the map to show the ordered on UI.
m["ordered_keys"] = registry.SortedMapKeys(m)
layersV1 = append(layersV1, m)
}
// Count image size
var imageSize int64
if gjson.Get(infoV2, "layers").Exists() {
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
imageSize = imageSize + s.Int()
}
} else {
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
}
}
// Count layers
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
// Gather sub-image info of multi-arch or cache image
var digestList []map[string]interface{}
for _, s := range manifests {
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
// Sub-image of the specific arch.
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
var dSize int64
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
dSize = dSize + d.Int()
}
r["size"] = dSize
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
}
} else {
// Sub-image of the cache type.
r["size"] = s.Get("size").Int()
}
r["ordered_keys"] = registry.SortedMapKeys(r)
digestList = append(digestList, r)
}
// Populate template vars
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tag", tag)
data.Set("repoPath", repoPath)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
data.Set("created", created)
data.Set("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
data.Set("isDigest", isDigest)
data.Set("digestList", digestList)
return c.Render(http.StatusOK, "tag_info.html", data)
}
func (a *apiClient) deleteTag(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
data := a.setUserPermissions(c)
if data["deleteAllowed"].Bool() {
a.client.DeleteTag(repoPath, tag)
}
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
// viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error {
data := a.setUserPermissions(c)
data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data)
}
// receiveEvents receive events.
func (a *apiClient) receiveEvents(c echo.Context) error {
a.eventListener.ProcessEvents(c.Request())
return c.String(http.StatusOK, "OK")
}