Initial commit.

This commit is contained in:
Roman Vynar 2018-02-19 17:12:59 +02:00
commit 8174df6fd7
20 changed files with 1411 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
config-dev.yml
data/registry_events.db
vendor/

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM golang:1.9.4-alpine3.7 as builder
ENV GOPATH /opt
RUN apk update && \
apk add ca-certificates git build-base && \
go get github.com/Masterminds/glide
ADD glide.* /opt/src/github.com/quiq/docker-registry-ui/
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
/opt/bin/glide install
ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry
ADD main.go /opt/src/github.com/quiq/docker-registry-ui/
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
go test -v ./registry && \
go build -o /opt/docker-registry-ui github.com/quiq/docker-registry-ui
FROM alpine:3.7
WORKDIR /opt
RUN apk add --no-cache ca-certificates && \
mkdir /opt/data
ADD templates /opt/templates
ADD static /opt/static
COPY --from=builder /opt/docker-registry-ui /opt/
USER nobody
ENTRYPOINT ["/opt/docker-registry-ui"]

13
LICENSE.md Normal file
View File

@ -0,0 +1,13 @@
Copyright 2017-2018 Quiq Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

65
README.md Normal file
View File

@ -0,0 +1,65 @@
## Docker Registry UI
### Overview
* Web UI for Docker Registry 2.6+
* Browse repositories and tags
* Display Docker image details by layers including both manifests v1 and v2
* Fast and small, written on Go
* Automatically discover an authentication method (basic auth, token service etc.)
* Caching the list of repositories, tag counts and refreshing in background
* Event listener of notification events coming from Registry
* CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags
No TLS or authentication implemented on the UI web server itself.
Assuming you will proxy it behind nginx, oauth2_proxy or something.
### Configuration
The configuration is stored in `config.yml` and the options are self-descriptive.
### Run UI
docker run -d -p 8000:8000 --read-only -v /local/config.yml:/opt/config.yml:ro \
--name=registry-ui quiq/docker-registry-ui
To run with your own root CA certificate, add to the command:
-v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro
To preserve sqlite db file with event notifications data, add to the command:
-v /local/data:/opt/data
## Configure event listener on Docker Registry
To receive events you need to configure Registry as follow:
notifications:
endpoints:
- name: docker-registry-ui
url: http://docker-registry-ui.local:8000/api/events
headers:
Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890]
timeout: 1s
threshold: 5
backoff: 10s
ignoredmediatypes:
- application/octet-stream
Adjust url and token as appropriate.
### Schedule a cron task for purging tags
The following example shows how to run a cron task to purge tags older than X days but also keep
at least Y tags no matter how old. Assuming container has been already running.
10 3 * * * root docker exec -t registry-ui /opt/docker-registry-ui -purge-tags
You can try to run in dry-run mode first to see what is going to be purged:
docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run
### Debug mode
To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`.

38
config.yml Normal file
View File

@ -0,0 +1,38 @@
# Listen interface.
listen_addr: 0.0.0.0:8000
# Registry URL with schema and port.
registry_url: https://docker-registry.local
# Verify TLS certificate when using https.
verify_tls: true
# Docker registry credentials.
# They need to have a full access to the registry.
# If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens.
registry_username: user
registry_password: pass
# Event listener token.
# The same one should be configured on Docker registry as Authorization Bearer token.
event_listener_token: token
# Retention of records to keep.
event_retention_days: 7
# Cache refresh interval in minutes.
# How long to cache repository list and tag counts.
cache_refresh_interval: 10
# If users can delete tags. If set to False, then only admins listed below.
anyone_can_delete: false
# Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy.
admins: []
# Debug mode. Affects only templates.
debug: true
# CLI options.
# 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

1
data/README.md Normal file
View File

@ -0,0 +1 @@
Directory for sqlite db file `registry_events.db`.

76
glide.lock generated Normal file
View File

@ -0,0 +1,76 @@
hash: 29246065eafa5aaec8848881fb6c99995e91a3f9b0082724db71c71590937e29
updated: 2018-02-19T16:47:40.847725+02:00
imports:
- name: github.com/CloudyKit/fastprinter
version: 74b38d55f37af5d6c05ca11147d616b613a3420e
- name: github.com/CloudyKit/jet
version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe
- name: github.com/dgrijalva/jwt-go
version: a539ee1a749a2b895533f979515ac7e6e0f5b650
- name: github.com/grafana/grafana
version: c4683f1ae85a9c80dfd04fce09318c466885f3c0
subpackages:
- pkg/cmd/grafana-cli/logger
- name: github.com/hhkbp2/go-logging
version: 1bf77adfece4a2018ac4bcc84e1f20509157a534
- name: github.com/hhkbp2/go-strftime
version: d82166ec6782f870431668391c2e321069632fe7
- name: github.com/labstack/echo
version: b338075a0fc6e1a0683dbf03d09b4957a289e26f
subpackages:
- middleware
- name: github.com/labstack/gommon
version: 779b8a8b9850a97acba6a3fe20feb628c39e17c1
subpackages:
- bytes
- color
- log
- random
- name: github.com/mattn/go-colorable
version: 3fa8c76f9daed4067e4a806fb7e4dc86455c6d6a
- name: github.com/mattn/go-isatty
version: fc9e8d8ef48496124e79ae0df75490096eccf6fe
- name: github.com/mattn/go-sqlite3
version: 6c771bb9887719704b210e87e934f08be014bdb1
- name: github.com/moul/http2curl
version: 4e24498b31dba4683efb9d35c1c8a91e2eda28c8
- name: github.com/parnurzeal/gorequest
version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3
- name: github.com/pkg/errors
version: c605e284fe17294bda444b34710735b29d1a9d90
- name: github.com/tidwall/gjson
version: 87033efcaec6215741137e8ca61952c53ef2685d
- name: github.com/tidwall/match
version: 173748da739a410c5b0b813b956f89ff94730b4c
- name: github.com/valyala/bytebufferpool
version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7
- name: github.com/valyala/fasttemplate
version: dcecefd839c4193db0d35b88ec65b4c12d360ab0
- name: golang.org/x/crypto
version: 4d70248d17d12d1edb7153434a74001c1540938b
subpackages:
- acme
- acme/autocert
- name: golang.org/x/net
version: 02ac38e2528ff4adea90f184d71a3faa04b4b1b0
subpackages:
- publicsuffix
- name: golang.org/x/sys
version: cd2c276457edda6df7fb04895d3fd6a6add42926
subpackages:
- unix
- name: gopkg.in/yaml.v2
version: 3b4ad1db5b2a649883ff3782f5f9f6fb52be71af
testImports:
- name: github.com/jtolds/gls
version: 9a4a02dbe491bef4bab3c24fd9f3087d6c4c6690
- name: github.com/smartystreets/assertions
version: 01fedaa993c0a9f9aa55111501cd7c81a49e812e
subpackages:
- internal/oglematchers
- name: github.com/smartystreets/goconvey
version: d4c757aa9afd1e2fc1832aaab209b5794eb336e1
subpackages:
- convey
- convey/gotest
- convey/reporting

20
glide.yaml Normal file
View File

@ -0,0 +1,20 @@
package: github.com/quiq/docker-registry-ui
import:
- package: github.com/CloudyKit/jet
version: v2.1.2
- package: github.com/labstack/echo
version: v3.2.6
subpackages:
- middleware
- package: github.com/parnurzeal/gorequest
version: v0.2.15
- package: github.com/hhkbp2/go-logging
- package: github.com/tidwall/gjson
version: v1.0.6
- package: github.com/mattn/go-sqlite3
version: 1.6.0
testImport:
- package: github.com/smartystreets/goconvey
version: 1.6.2
subpackages:
- convey

293
main.go Normal file
View File

@ -0,0 +1,293 @@
package main
import (
"flag"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
"gopkg.in/yaml.v2"
)
type configData struct {
ListenAddr string `yaml:"listen_addr"`
RegistryURL string `yaml:"registry_url"`
VerifyTLS bool `yaml:"verify_tls"`
Username string `yaml:"registry_username"`
Password string `yaml:"registry_password"`
EventListenerToken string `yaml:"event_listener_token"`
EventRetentionDays int `yaml:"event_retention_days"`
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"`
}
type template struct {
View *jet.Set
}
type apiClient struct {
client *registry.Client
config configData
}
func main() {
var (
a apiClient
configFile string
purgeTags bool
purgeDryRun bool
)
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server")
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
flag.Parse()
// 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)
}
// Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
if a.client == nil {
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
}
// Execute CLI task and exit.
if purgeTags {
registry.PurgeOldTags(a.client, purgeDryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount)
return
}
// Count tags in background.
go a.client.CountTags(a.config.CacheRefreshInterval)
// Template engine init.
view := jet.NewHTMLSet("templates")
view.SetDevelopmentMode(a.config.Debug)
view.AddGlobal("registryHost", u.Host)
view.AddGlobal("pretty_size", func(size interface{}) string {
var value float64
switch i := size.(type) {
case gjson.Result:
value = float64(i.Int())
case int64:
value = float64(i)
}
return registry.PrettySize(value)
})
view.AddGlobal("pretty_time", func(datetime interface{}) string {
return strings.Split(strings.Replace(datetime.(string), "T", " ", 1), ".")[0]
})
view.AddGlobal("parse_map", func(m interface{}) string {
var res string
for _, k := range registry.SortedMapKeys(m) {
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
}
return res
})
e := echo.New()
e.Renderer = &template{View: view}
// Web routes.
e.Static("/static", "static")
e.GET("/", a.viewRepositories)
e.GET("/:namespace", a.viewRepositories)
e.GET("/:namespace/:repo", a.viewTags)
e.GET("/:namespace/:repo/:tag", a.viewTagInfo)
e.GET("/:namespace/:repo/:tag/delete", a.deleteTag)
e.GET("/events", a.viewLog)
// Protected event listener.
p := e.Group("/api")
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
return token == a.config.EventListenerToken, nil
}),
}))
p.POST("/events", a.eventListener)
e.Logger.Fatal(e.Start(a.config.ListenAddr))
}
// Render render template.
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))
}
vars, ok := data.(jet.VarMap)
if !ok {
vars = jet.VarMap{}
}
err = t.Execute(w, vars, nil)
if err != nil {
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
}
return nil
}
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)
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)
}
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
if infoV1 == "" || infoV2 == "" {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo))
}
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()
}
}
var layersV2 []map[string]gjson.Result
for _, s := range gjson.Get(infoV2, "layers").Array() {
layersV2 = append(layersV2, s.Map())
}
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)
}
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
data.Set("tag", gjson.Get(infoV1, "tag").String())
data.Set("repoPath", gjson.Get(infoV1, "name").String())
data.Set("created", gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String())
data.Set("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
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", 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 {
events := registry.GetEvents()
data := jet.VarMap{}
data.Set("events", events)
return c.Render(http.StatusOK, "event_log.html", data)
}
// eventListener listen events from registry.
func (a *apiClient) eventListener(c echo.Context) error {
registry.ProcessEvents(c.Request(), a.config.EventRetentionDays)
return c.String(http.StatusOK, "OK")
}

245
registry/client.go Normal file
View File

@ -0,0 +1,245 @@
package registry
import (
"crypto/tls"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/hhkbp2/go-logging"
"github.com/parnurzeal/gorequest"
"github.com/tidwall/gjson"
)
// Client main class.
type Client struct {
url string
verifyTLS bool
username string
password string
request *gorequest.SuperAgent
logger logging.Logger
mux sync.Mutex
tokens map[string]string
repos map[string][]string
tagCounts map[string]int
authURL string
}
// NewClient initialize Client.
func NewClient(url string, verifyTLS bool, username, password string) *Client {
c := &Client{
url: strings.TrimRight(url, "/"),
verifyTLS: verifyTLS,
username: username,
password: password,
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
logger: setupLogging("registry.client"),
tokens: map[string]string{},
repos: map[string][]string{},
tagCounts: map[string]int{},
}
resp, _, errs := c.request.Get(c.url+"/v2/").Set("User-Agent", "docker-registry-ui").End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return nil
}
authHeader := ""
if resp.StatusCode == 200 {
return c
} else if resp.StatusCode == 401 {
authHeader = resp.Header.Get("WWW-Authenticate")
} else {
c.logger.Error(resp.Status)
return nil
}
if strings.HasPrefix(authHeader, "Bearer") {
r, _ := regexp.Compile(`^Bearer realm="(http.+)",service="(.+)"`)
if m := r.FindStringSubmatch(authHeader); len(m) > 0 {
c.authURL = fmt.Sprintf("%s?service=%s", m[1], m[2])
c.logger.Info("Token auth service discovered at ", c.authURL)
}
if c.authURL == "" {
c.logger.Warn("No token auth service discovered from ", c.url)
return nil
}
} else if strings.HasPrefix(authHeader, "Basic") {
c.request = c.request.SetBasicAuth(c.username, c.password)
c.logger.Info("It was discovered the registry is configured with HTTP basic auth.")
}
return c
}
// getToken get existing or new auth token.
func (c *Client) getToken(scope string) string {
// Check if we have already a token and it's not expired.
if token, ok := c.tokens[scope]; ok {
resp, _, _ := c.request.Get(c.url+"/v2/").Set("Authorization", fmt.Sprintf("Bearer %s", token)).Set("User-Agent", "docker-registry-ui").End()
if resp != nil && resp.StatusCode == 200 {
return token
}
}
request := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !c.verifyTLS})
resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).SetBasicAuth(c.username, c.password).Set("User-Agent", "docker-registry-ui").End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return ""
}
if resp.StatusCode != 200 {
c.logger.Error("Failed to get token for scope ", scope, " from ", c.authURL)
return ""
}
c.tokens[scope] = gjson.Get(data, "token").String()
c.logger.Info("Received new token for scope ", scope)
return c.tokens[scope]
}
// callRegistry make an HTTP request to Docker registry.
func (c *Client) callRegistry(uri, scope string, manifest uint, delete bool) (rdata, rdigest string) {
acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.manifest.v%d+json", manifest)
authHeader := ""
if c.authURL != "" {
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
}
resp, data, errs := c.request.Get(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return "", ""
}
c.logger.Info("GET ", uri, " ", resp.Status)
// Returns 404 when no tags in the repo.
if resp.StatusCode != 200 {
return "", ""
}
digest := resp.Header.Get("Docker-Content-Digest")
if delete {
// Delete by manifest digest reference.
parts := strings.Split(uri, "/manifests/")
uri = parts[0] + "/manifests/" + digest
resp, _, errs := c.request.Delete(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End()
if len(errs) > 0 {
c.logger.Error(errs[0])
} else {
// Returns 202 on success.
c.logger.Info("DELETE ", uri, " (", parts[1], ") ", resp.Status)
}
return "", ""
}
return data, digest
}
// Namespaces list repo namespaces.
func (c *Client) Namespaces() []string {
namespaces := make([]string, 0, len(c.repos))
for k := range c.repos {
namespaces = append(namespaces, k)
}
sort.Strings(namespaces)
return namespaces
}
// Repositories list repos by namespaces where 'library' is the default one.
func (c *Client) Repositories(useCache bool) map[string][]string {
// Return from cache if available.
if len(c.repos) > 0 && useCache {
return c.repos
}
c.mux.Lock()
defer c.mux.Unlock()
scope := "registry:catalog:*"
data, _ := c.callRegistry("/v2/_catalog", scope, 2, false)
if data == "" {
return c.repos
}
c.repos = map[string][]string{}
for _, r := range gjson.Get(data, "repositories").Array() {
namespace := "library"
repo := r.String()
if strings.Contains(repo, "/") {
f := strings.SplitN(repo, "/", 2)
namespace = f[0]
repo = f[1]
}
c.repos[namespace] = append(c.repos[namespace], repo)
}
return c.repos
}
// Tags get tags for the repo.
func (c *Client) Tags(repo string) []string {
scope := fmt.Sprintf("repository:%s:*", repo)
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, 2, false)
var tags []string
for _, t := range gjson.Get(data, "tags").Array() {
tags = append(tags, t.String())
}
return tags
}
// TagInfo get image info for the repo tag.
func (c *Client) TagInfo(repo, tag string, v1only bool) (rsha256, rinfoV1, rinfoV2 string) {
scope := fmt.Sprintf("repository:%s:*", repo)
infoV1, _ := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 1, false)
if infoV1 == "" {
return "", "", ""
}
if v1only {
return "", infoV1, ""
}
infoV2, digest := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, false)
if infoV2 == "" || digest == "" {
return "", "", ""
}
sha256 := digest[7:]
return sha256, infoV1, infoV2
}
// TagCounts return map with tag counts.
func (c *Client) TagCounts() map[string]int {
return c.tagCounts
}
// CountTags count repository tags in background regularly.
func (c *Client) CountTags(interval uint8) {
for {
c.logger.Info("Calculating tags in background...")
catalog := c.Repositories(false)
for n, repos := range catalog {
for _, r := range repos {
repoPath := r
if n != "library" {
repoPath = fmt.Sprintf("%s/%s", n, r)
}
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
}
}
c.logger.Info("Tags calculation complete.")
time.Sleep(time.Duration(interval) * time.Minute)
}
}
// DeleteTag delete image tag.
func (c *Client) DeleteTag(repo, tag string) {
scope := fmt.Sprintf("repository:%s:*", repo)
c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, true)
}

44
registry/common.go Normal file
View File

@ -0,0 +1,44 @@
package registry
import (
"fmt"
"reflect"
"sort"
"github.com/hhkbp2/go-logging"
)
// setupLogging configure logging.
func setupLogging(name string) logging.Logger {
logger := logging.GetLogger(name)
handler := logging.NewStdoutHandler()
format := "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
dateFormat := "%Y-%m-%d %H:%M:%S"
formatter := logging.NewStandardFormatter(format, dateFormat)
handler.SetFormatter(formatter)
logger.SetLevel(logging.LevelInfo)
logger.AddHandler(handler)
return logger
}
// SortedMapKeys sort keys of the map where values can be of any type.
func SortedMapKeys(m interface{}) []string {
v := reflect.ValueOf(m)
keys := make([]string, 0, len(v.MapKeys()))
for _, key := range v.MapKeys() {
keys = append(keys, key.String())
}
sort.Strings(keys)
return keys
}
// PrettySize format bytes in more readable units.
func PrettySize(size float64) string {
units := []string{"B", "KB", "MB", "GB"}
i := 0
for size > 1024 && i < len(units) {
size = size / 1024
i = i + 1
}
return fmt.Sprintf("%.*f %s", 0, size, units[i])
}

47
registry/common_test.go Normal file
View File

@ -0,0 +1,47 @@
package registry
import (
"testing"
"time"
"github.com/smartystreets/goconvey/convey"
)
func TestSortedMapKeys(t *testing.T) {
a := map[string]string{
"foo": "bar",
"abc": "bar",
"zoo": "bar",
}
b := map[string]timeSlice{
"zoo": []tagData{tagData{name: "1", created: time.Now()}},
"abc": []tagData{tagData{name: "1", created: time.Now()}},
"foo": []tagData{tagData{name: "1", created: time.Now()}},
}
c := map[string][]string{
"zoo": []string{"1", "2"},
"foo": []string{"1", "2"},
"abc": []string{"1", "2"},
}
expect := []string{"abc", "foo", "zoo"}
convey.Convey("Sort map keys", t, func() {
convey.So(SortedMapKeys(a), convey.ShouldResemble, expect)
convey.So(SortedMapKeys(b), convey.ShouldResemble, expect)
convey.So(SortedMapKeys(c), convey.ShouldResemble, expect)
})
}
func TestPrettySize(t *testing.T) {
convey.Convey("Format bytes", t, func() {
input := map[float64]string{
123: "123 B",
23123: "23 KB",
23923: "23 KB",
723425120: "690 MB",
8534241213: "8 GB",
}
for key, val := range input {
convey.So(PrettySize(key), convey.ShouldEqual, val)
}
})
}

128
registry/event_listener.go Normal file
View File

@ -0,0 +1,128 @@
package registry
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
// 🐒 patching of "database/sql".
_ "github.com/mattn/go-sqlite3"
"github.com/tidwall/gjson"
)
const (
dbFile = "data/registry_events.db"
schema = `
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action CHAR(4) NULL,
repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL,
user VARCHAR(50) NULL,
created DATETIME NULL
);
`
)
type eventData struct {
Events []interface{} `json:"events"`
}
// EventRow event row from sqlite
type EventRow struct {
ID int
Action string
Repository string
Tag string
IP string
User string
Created time.Time
}
// ProcessEvents parse and store registry events
func ProcessEvents(request *http.Request, retention int) {
logger := setupLogging("registry.event_listener")
decoder := json.NewDecoder(request.Body)
var t eventData
if err := decoder.Decode(&t); err != nil {
logger.Errorf("Problem decoding event from request: %+v", request)
return
}
logger.Debugf("Received event: %+v", t)
j, _ := json.Marshal(t)
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
logger.Error("Error opening sqlite db: ", err)
return
}
defer db.Close()
_, err = db.Exec(schema)
if err != nil {
logger.Error("Error creating a table: ", err)
return
}
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?,DateTime('now'))")
for _, e := range gjson.GetBytes(j, "events").Array() {
// Ignore calls by docker-registry-ui itself.
if e.Get("request.useragent").String() == "docker-registry-ui" {
continue
}
action := e.Get("action").String()
repository := e.Get("target.repository").String()
tag := e.Get("target.tag").String()
// Tag is empty in case of signed pull.
if tag == "" {
tag = e.Get("target.digest").String()
}
ip := strings.Split(e.Get("request.addr").String(), ":")[0]
user := e.Get("actor.name").String()
logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
res, err := stmt.Exec(action, repository, tag, ip, user)
if err != nil {
logger.Error("Error inserting a row: ", err)
return
}
id, _ := res.LastInsertId()
logger.Debug("New event added with id ", id)
}
// Purge old records.
stmt, _ = db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)")
res, _ := stmt.Exec(fmt.Sprintf("-%d day", retention))
count, _ := res.RowsAffected()
logger.Debug("Rows deleted: ", count)
}
// GetEvents retrieve events from sqlite db
func GetEvents() []EventRow {
var events []EventRow
db, err := sql.Open("sqlite3", dbFile)
if err != nil {
logger.Error("Error opening sqlite db: ", err)
return events
}
defer db.Close()
rows, err := db.Query("SELECT * FROM events ORDER BY id DESC LIMIT 1000")
if err != nil {
logger.Error("Error selecting from table: ", err)
return events
}
for rows.Next() {
var row EventRow
rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created)
events = append(events, row)
}
rows.Close()
return events
}

132
registry/tasks.go Normal file
View File

@ -0,0 +1,132 @@
package registry
import (
"fmt"
"sort"
"time"
"github.com/hhkbp2/go-logging"
"github.com/tidwall/gjson"
)
type tagData struct {
name string
created time.Time
}
func (t tagData) String() string {
return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05"))
}
type timeSlice []tagData
func (p timeSlice) Len() int {
return len(p)
}
func (p timeSlice) Less(i, j int) bool {
return p[i].created.After(p[j].created)
}
func (p timeSlice) Swap(i, j int) {
p[i], p[j] = p[j], p[i]
}
// PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) {
logger := setupLogging("registry.tasks.PurgeOldTags")
// Reduce client logging.
client.logger.SetLevel(logging.LevelError)
dryRunText := ""
if purgeDryRun {
logger.Warn("Dry-run mode enabled.")
dryRunText = "skipped"
}
logger.Info("Scanning registry for repositories, tags and their creation dates...")
catalog := client.Repositories(true)
// catalog := map[string][]string{"library": []string{""}}
now := time.Now().UTC()
repos := map[string]timeSlice{}
count := 0
for namespace := range catalog {
count = count + len(catalog[namespace])
for _, repo := range catalog[namespace] {
if namespace != "library" {
repo = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := client.Tags(repo)
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
if len(tags) == 0 {
continue
}
for _, tag := range tags {
_, infoV1, _ := client.TagInfo(repo, tag, true)
if infoV1 == "" {
logger.Errorf("[%s] manifest missed for tag %s", repo, tag)
continue
}
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").Time()
repos[repo] = append(repos[repo], tagData{name: tag, created: created})
}
}
}
logger.Infof("Scanned %d repositories.", count)
logger.Info("Filtering out tags for purging...")
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
// Filter out tags by retention days.
for _, tag := range repos[repo] {
delta := int(now.Sub(tag.created).Hours() / 24)
if delta > purgeTagsKeepDays {
purgeTags[repo] = append(purgeTags[repo], tag.name)
} else {
keepTags[repo] = append(keepTags[repo], tag.name)
}
}
// 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)
}
}
count = count + len(purgeTags[repo])
logger.Infof("[%s] All %d: %v", repo, len(repos[repo]), repos[repo])
logger.Infof("[%s] Keep %d: %v", repo, len(keepTags[repo]), keepTags[repo])
logger.Infof("[%s] Purge %d: %v", repo, len(purgeTags[repo]), purgeTags[repo])
}
logger.Infof("There are %d tags to purge.", count)
if count > 0 {
logger.Info("Purging old tags...")
}
for _, repo := range SortedMapKeys(purgeTags) {
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
if purgeDryRun {
continue
}
for _, tag := range purgeTags[repo] {
client.DeleteTag(repo, tag)
}
}
logger.Info("Done.")
}

7
static/bootstrap-confirmation.min.js vendored Executable file

File diff suppressed because one or more lines are too long

31
templates/base.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Docker Registry UI</title>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/r/bs-3.3.5/jq-2.1.4,dt-1.10.8/datatables.min.css" />
<script type="text/javascript" src="https://cdn.datatables.net/r/bs-3.3.5/jqc-1.11.3,dt-1.10.8/datatables.min.js"></script>
{{yield head()}}
</head>
<body>
<div class="container">
<div style="float: left">
<h2>Docker Registry UI</h2>
</div>
<div style="float: right">
<a href="/events">Event Log</a>
</div>
<div style="clear: both"></div>
{{yield body()}}
<div style="padding: 10px 0; margin-bottom: 20px">
<div style="float: left">
&copy; 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
</div>
</div>
</div>
</body>
</html>

47
templates/event_log.html Normal file
View File

@ -0,0 +1,47 @@
{{extends "base.html"}}
{{block head()}}
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
"pageLength": 10,
"order": [[ 4, 'desc' ]],
"stateSave": true
});
});
</script>
{{end}}
{{block body()}}
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
<li class="active">Event Log</li>
</ol>
<table id="datatable" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Action</th>
<th>Image</th>
<th>IP Address</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{{range e := events}}
<tr>
<td>{{ e.Action }}</td>
{{if hasPrefix(e.Tag,"sha256") }}
<td>{{ e.Repository }}@{{ e.Tag[:12] }}.....{{ e.Tag[66:] }}</td>
{{else}}
<td>{{ e.Repository }}:{{ e.Tag }}</td>
{{end}}
<td>{{ e.IP }}</td>
<td>{{ e.User }}</td>
<td>{{ e.Created }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@ -0,0 +1,53 @@
{{extends "base.html"}}
{{block head()}}
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
"pageLength": 25,
"stateSave": true
});
$('#namespace').on('change', function (e) {
window.location = '/' + this.value;
});
if (window.location.pathname == '/') {
namespace = 'library';
} else {
namespace = window.location.pathname.split('/')[1]
}
$('#namespace').val(namespace);
});
</script>
{{end}}
{{block body()}}
<div style="float: right">
<select id="namespace" class="form-control input-sm" style="height: 36px">
<option value="" disabled>-- Namespace --</option>
{{range namespace := namespaces}}
<option value="{{ namespace }}">{{ namespace }}</option>
{{end}}
</select>
</div>
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
</ol>
<table id="datatable" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Repository</th>
<th width="20%">Tags</th>
</tr>
</thead>
<tbody>
{{range repo := repos}}
<tr>
<td><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></td>
<td>{{ tagCounts[namespace+"/"+repo] }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}

85
templates/tag_info.html Normal file
View File

@ -0,0 +1,85 @@
{{extends "base.html"}}
{{block head()}}{{end}}
{{block body()}}
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
{{if namespace != "library"}}
<li><a href="/{{ namespace }}">{{ namespace }}</a></li>
{{end}}
<li><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></li>
<li class="active">{{ tag }}</li>
</ol>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Image Details</th>
</tr>
</thead>
<tr>
<td width="20%">Image</td><td>{{ registryHost }}/{{ repoPath }}:{{ tag }}</td>
</tr>
<tr>
<td>sha256</td><td>{{ sha256 }}</td>
</tr>
<tr>
<td>Created On</td><td>{{ created|pretty_time }}</td>
</tr>
<tr>
<td>Image Size</td><td>{{ imageSize|pretty_size }}</td>
</tr>
<tr>
<td>Layer Count</td><td>{{ layersCount }}</td>
</tr>
</table>
{{if layersV2}}
<h4>Manifest v2</h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Layer #</th>
<th>Digest</th>
<th>Size</th>
</tr>
</thead>
{{range index, layer := layersV2}}
<tr>
<td>{{ len(layersV2)-index }}</td>
<td>{{ layer["digest"] }}</td>
<td>{{ layer["size"]|pretty_size }}</td>
</tr>
{{end}}
</table>
{{end}}
<h4>Manifest v1</h4>
{{range index, layer := layersV1}}
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Layer #{{ len(layersV1)-index }}</th>
</tr>
</thead>
{{range key := layer["ordered_keys"]}}
<tr>
<td width="20%">{{ key }}</td>
{{if key == "config" || key == "container_config"}}
<td style="padding: 0">
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
<!-- Nested range does not work. Iterating via filter over the map. -->
{{ layer[key]|parse_map|raw }}
</table>
</td>
{{else if key == "created"}}
<td>{{ layer[key]|pretty_time }}</td>
{{else}}
<td>{{ layer[key] }}</td>
{{end}}
</tr>
{{end}}
</table>
{{end}}
{{end}}

52
templates/tags.html Normal file
View File

@ -0,0 +1,52 @@
{{extends "base.html"}}
{{block head()}}
<script type="text/javascript" src="/static/bootstrap-confirmation.min.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
"pageLength": 10,
"order": [[ 0, 'desc' ]],
"stateSave": true
})
function populateConfirmation() {
$('[data-toggle=confirmation]').confirmation({
rootSelector: '[data-toggle=confirmation]',
container: 'body'
});
}
populateConfirmation()
$('#datatable').on('draw.dt', populateConfirmation)
});
</script>
{{end}}
{{block body()}}
<ol class="breadcrumb">
<li><a href="/">Home</a></li>
{{if namespace != "library"}}
<li><a href="/{{ namespace }}">{{ namespace }}</a></li>
{{end}}
<li class="active">{{ repo }}</li>
</ol>
<table id="datatable" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Tag Name</th>
</tr>
</thead>
<tbody>
{{range tag := tags}}
<tr>
<td>
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a>
{{if deleteAllowed}}
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}