mirror of
https://github.com/Quiq/docker-registry-ui.git
synced 2025-07-18 08:11:15 +00:00
Initial commit.
This commit is contained in:
commit
8174df6fd7
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
config-dev.yml
|
||||||
|
data/registry_events.db
|
||||||
|
vendor/
|
31
Dockerfile
Normal file
31
Dockerfile
Normal 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
13
LICENSE.md
Normal 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
65
README.md
Normal 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
38
config.yml
Normal 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
1
data/README.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
Directory for sqlite db file `registry_events.db`.
|
76
glide.lock
generated
Normal file
76
glide.lock
generated
Normal 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
20
glide.yaml
Normal 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
293
main.go
Normal 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
245
registry/client.go
Normal 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
44
registry/common.go
Normal 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
47
registry/common_test.go
Normal 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
128
registry/event_listener.go
Normal 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
132
registry/tasks.go
Normal 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
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
31
templates/base.html
Normal 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">
|
||||||
|
© 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
47
templates/event_log.html
Normal file
47
templates/event_log.html
Normal 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}}
|
53
templates/repositories.html
Normal file
53
templates/repositories.html
Normal 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
85
templates/tag_info.html
Normal 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
52
templates/tags.html
Normal 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}}
|
Loading…
Reference in New Issue
Block a user