diff --git a/.gitignore b/.gitignore index 7d21d57..19e0d17 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ -config-dev.yml data/registry_events.db -vendor/ +config-dev.yml +dev.Makefile +keep_tags.json diff --git a/CHANGELOG.md b/CHANGELOG.md index bdab753..68785c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ ## Changelog +### 0.9.5 (unreleased) + +* Upgrade Go version to 1.19.0, alpine to 3.16 and other dependencies. +* Added an option `purge_tags_keep_regexp` to preserve tags based on regexp +* Added an option `purge_tags_keep_from_file` to preserve tags for repos listed in the file provided +* Fix for a bug when there was a bit more tags preserved than defined by `purge_tags_keep_count` + ### 0.9.4 (2022-04-06) * Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies. diff --git a/README.md b/README.md index 9c2d060..d769764 100644 --- a/README.md +++ b/README.md @@ -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: - purge_tags_keep_days: 90 - purge_tags_keep_count: 2 purge_tags_schedule: '0 10 3 * * *' Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron diff --git a/config.go b/config.go new file mode 100644 index 0000000..9fc262c --- /dev/null +++ b/config.go @@ -0,0 +1,93 @@ +package main + +import ( + "io/ioutil" + "net/url" + "os" + "strings" + + "github.com/quiq/docker-registry-ui/registry" + "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"` + 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"` + PurgeTagsKeepFile 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. + if config.BasePath != "" { + config.BasePath = "/" + strings.Trim(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 = ®istry.PurgeTagsConfig{ + KeepDays: config.PurgeTagsKeepDays, + KeepMinCount: config.PurgeTagsKeepCount, + KeepTagRegexp: config.PurgeTagsKeepRegexp, + } + if config.PurgeTagsKeepFile != "" { + if _, err := os.Stat(config.PurgeTagsKeepFile); os.IsNotExist(err) { + panic(err) + } + data, err := ioutil.ReadFile(config.PurgeTagsKeepFile) + if err != nil { + panic(err) + } + config.PurgeConfig.KeepTagsFromFile = gjson.ParseBytes(data) + } + + return &config +} diff --git a/config.yml b/config.yml index 4e60008..cab680a 100644 --- a/config.yml +++ b/config.yml @@ -32,7 +32,7 @@ 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 # cluster setup to avoid deadlocks or replication break. -event_deletion_enabled: True +event_deletion_enabled: true # Cache refresh interval in minutes. # How long to cache repository list and tag counts. @@ -50,8 +50,16 @@ debug: true # 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_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. # Empty string disables this feature. # Example: '25 54 17 * * *' will run it at 17:54:25 daily. diff --git a/main.go b/main.go index a772db7..53c692c 100644 --- a/main.go +++ b/main.go @@ -3,54 +3,20 @@ package main import ( "flag" "fmt" - "io/ioutil" - "net/http" "net/url" - "os" - "strings" - "github.com/CloudyKit/jet" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/quiq/docker-registry-ui/events" "github.com/quiq/docker-registry-ui/registry" "github.com/robfig/cron" "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"` - 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 { client *registry.Client eventListener *events.EventListener - config configData + config *configData } func main() { @@ -66,48 +32,16 @@ func main() { flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything") flag.Parse() + // Setup logging if loggingLevel != "info" { if level, err := logrus.ParseLevel(loggingLevel); err == nil { logrus.SetLevel(level) } } - // Read config file. - if _, err := os.Stat(configFile); os.IsNotExist(err) { - panic(err) - } - 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") - } + // Read config file + a.config = readConfig(configFile) + a.config.PurgeConfig.DryRun = purgeDryRun // Init registry API client. a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password) @@ -115,19 +49,21 @@ func main() { 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. if purgeTags { - a.purgeOldTags(purgeDryRun) + purgeFunc() return } + // Schedules to purge tags. if a.config.PurgeTagsSchedule != "" { c := cron.New() - task := func() { - a.purgeOldTags(purgeDryRun) - } - if err := c.AddFunc(a.config.PurgeTagsSchedule, task); err != nil { - panic(fmt.Errorf("Invalid schedule format: %s", a.config.PurgeTagsSchedule)) + if err := c.AddFunc(a.config.PurgeTagsSchedule, purgeFunc); err != nil { + panic(fmt.Errorf("invalid schedule format: %s", a.config.PurgeTagsSchedule)) } c.Start() } @@ -144,7 +80,8 @@ func main() { // Template engine init. 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. e.File("/favicon.ico", "static/favicon.ico") @@ -170,187 +107,3 @@ func main() { 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 := jet.VarMap{} - 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) - deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) - - data := jet.VarMap{} - data.Set("namespace", namespace) - data.Set("repo", repo) - data.Set("tags", tags) - data.Set("deleteAllowed", deleteAllowed) - 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(`%s`, 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 := jet.VarMap{} - 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)) -} - -// 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 -} - -// viewLog view events from sqlite. -func (a *apiClient) viewLog(c echo.Context) error { - data := jet.VarMap{} - 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) -} diff --git a/registry/common_test.go b/registry/common_test.go index 01d4134..d064043 100644 --- a/registry/common_test.go +++ b/registry/common_test.go @@ -1,12 +1,32 @@ package registry import ( + "math" "testing" "time" "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) { a := map[string]string{ "foo": "bar", diff --git a/registry/tasks.go b/registry/tasks.go index 7a9434b..b608893 100644 --- a/registry/tasks.go +++ b/registry/tasks.go @@ -2,6 +2,7 @@ package registry import ( "fmt" + "math" "regexp" "sort" "time" @@ -9,6 +10,14 @@ import ( "github.com/tidwall/gjson" ) +type PurgeTagsConfig struct { + DryRun bool + KeepDays int + KeepMinCount int + KeepTagRegexp string + KeepTagsFromFile gjson.Result +} + type tagData struct { name string created time.Time @@ -37,10 +46,10 @@ func (p timeSlice) Swap(i, j int) { } // 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") dryRunText := "" - if purgeDryRun { + if config.DryRun { logger.Warn("Dry-run mode enabled.") dryRunText = "skipped" } @@ -75,27 +84,35 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags } 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.KeepTagsFromFile.IsObject() { + logger.Infof("Keeping tags for repos from the file: %+v", config.KeepTagsFromFile) + } purgeTags := map[string][]string{} keepTags := map[string][]string{} count = 0 for _, repo := range SortedMapKeys(repos) { // Sort tags by "created" from newest to oldest. - sortedTags := make(timeSlice, 0, len(repos[repo])) - for _, d := range repos[repo] { - sortedTags = append(sortedTags, d) - } - sort.Sort(sortedTags) - repos[repo] = sortedTags + sort.Sort(repos[repo]) - // Filter out tags by retention days and regexp + // Prep the list of tags to preserve if defined in the file + tagsFromFile := []string{} + for _, i := range config.KeepTagsFromFile.Get(repo).Array() { + tagsFromFile = append(tagsFromFile, i.String()) + } + + // Filter out tags for _, tag := range repos[repo] { - regexpKeep := false - if purgeTagsKeepRegexp != "" { - regexpKeep, _ = regexp.MatchString(purgeTagsKeepRegexp, tag.name) + daysOld := int(now.Sub(tag.created).Hours() / 24) + keepByRegexp := false + if config.KeepTagRegexp != "" { + keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name) } - delta := int(now.Sub(tag.created).Hours() / 24) - if !regexpKeep && delta > purgeTagsKeepDays { + + if daysOld > config.KeepDays && !keepByRegexp && !ItemInSlice(tag.name, tagsFromFile) { purgeTags[repo] = append(purgeTags[repo], tag.name) } else { keepTags[repo] = append(keepTags[repo], tag.name) @@ -103,14 +120,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags } // Keep minimal count of tags no matter how old they are. - if len(repos[repo])-len(purgeTags[repo]) < purgeTagsKeepCount { - if len(purgeTags[repo]) > purgeTagsKeepCount { - keepTags[repo] = append(keepTags[repo], purgeTags[repo][:purgeTagsKeepCount]...) - purgeTags[repo] = purgeTags[repo][purgeTagsKeepCount:] - } else { - keepTags[repo] = append(keepTags[repo], purgeTags[repo]...) - delete(purgeTags, repo) - } + if len(keepTags[repo]) < config.KeepMinCount { + // At least "threshold"-"keep" but not more than available for "purge". + takeFromPurge := int(math.Min(float64(config.KeepMinCount-len(keepTags[repo])), float64(len(purgeTags[repo])))) + keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...) + purgeTags[repo] = purgeTags[repo][takeFromPurge:] } count = count + len(purgeTags[repo]) @@ -125,8 +139,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags } for _, repo := range SortedMapKeys(purgeTags) { + if len(purgeTags[repo]) == 0 { + continue + } logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText) - if purgeDryRun { + if config.DryRun { continue } for _, tag := range purgeTags[repo] { diff --git a/template.go b/template.go index cb885e6..e201c32 100644 --- a/template.go +++ b/template.go @@ -21,7 +21,7 @@ type Template struct { func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error { t, err := r.View.GetTemplate(name) 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) 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) 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 } diff --git a/templates/base.html b/templates/base.html index d51e4dd..b1f6163 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,7 +23,7 @@
diff --git a/version.go b/version.go index 529a12e..7d409fe 100644 --- a/version.go +++ b/version.go @@ -1,3 +1,3 @@ package main -const version = "0.9.4" +const version = "0.9.5" diff --git a/web.go b/web.go new file mode 100644 index 0000000..c49880b --- /dev/null +++ b/web.go @@ -0,0 +1,192 @@ +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" +) + +func (a *apiClient) viewRepositories(c echo.Context) error { + namespace := c.Param("namespace") + if namespace == "" { + namespace = "library" + } + + repos := a.client.Repositories(true)[namespace] + data := jet.VarMap{} + 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) + deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) + + data := jet.VarMap{} + data.Set("namespace", namespace) + data.Set("repo", repo) + data.Set("tags", tags) + data.Set("deleteAllowed", deleteAllowed) + 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(`%s`, 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 := jet.VarMap{} + 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)) +} + +// 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 +} + +// viewLog view events from sqlite. +func (a *apiClient) viewLog(c echo.Context) error { + data := jet.VarMap{} + 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") +}