Added support for nested namespaces

This commit is contained in:
Kupai József 2020-03-22 13:35:11 +00:00
parent c424093470
commit ae0e8f570d
9 changed files with 132 additions and 197 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
config-dev.yml config-dev.yml
data/registry_events.db data/registry_events.db
vendor/ vendor/
*.swp

View File

@ -6,8 +6,11 @@ RUN apk update && \
WORKDIR /opt/src WORKDIR /opt/src
ADD events events ADD events events
ADD registry registry ADD registry registry
ADD *.go go.mod go.sum ./
ADD go.mod go.sum ./
RUN go mod download
ADD *.go ./
RUN go test -v ./registry && \ RUN go test -v ./registry && \
go build -o /opt/docker-registry-ui *.go go build -o /opt/docker-registry-ui *.go

112
main.go
View File

@ -7,6 +7,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"regexp"
"strings" "strings"
"github.com/CloudyKit/jet" "github.com/CloudyKit/jet"
@ -52,6 +53,21 @@ type apiClient struct {
config configData config configData
} }
type breadCrumb struct {
segment string
path string
}
func getBreadCrumbs(path string) []breadCrumb {
ret := []breadCrumb{}
segments := strings.Split(path, "/")
for i := 0; i < len(segments); i++ {
e := breadCrumb{segment: segments[i], path: strings.Join(segments[0:i+1], "/")}
ret = append(ret, e)
}
return ret
}
func main() { func main() {
var ( var (
a apiClient a apiClient
@ -149,13 +165,10 @@ func main() {
e.File("/favicon.ico", "static/favicon.ico") e.File("/favicon.ico", "static/favicon.ico")
e.Static(a.config.BasePath+"/static", "static") e.Static(a.config.BasePath+"/static", "static")
if a.config.BasePath != "" { if a.config.BasePath != "" {
e.GET(a.config.BasePath, a.viewRepositories) e.GET(a.config.BasePath, a.dispatchRequest)
} }
e.GET(a.config.BasePath+"/", a.viewRepositories) e.GET(a.config.BasePath+"/", a.dispatchRequest)
e.GET(a.config.BasePath+"/:namespace", a.viewRepositories) e.GET(a.config.BasePath+"/*", a.dispatchRequest)
e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
e.GET(a.config.BasePath+"/events", a.viewLog) e.GET(a.config.BasePath+"/events", a.viewLog)
// Protected event listener. // Protected event listener.
@ -170,58 +183,65 @@ func main() {
e.Logger.Fatal(e.Start(a.config.ListenAddr)) e.Logger.Fatal(e.Start(a.config.ListenAddr))
} }
func (a *apiClient) viewRepositories(c echo.Context) error { func (a *apiClient) dispatchRequest(c echo.Context) error {
namespace := c.Param("namespace") path := c.Request().URL.Path
if namespace == "" { if strings.HasPrefix(path, a.config.BasePath) {
namespace = "library" path = path[len(a.config.BasePath):]
}
if strings.HasPrefix(path, "/") {
path = path[1:]
} }
repos, _ := a.client.Repositories(true)[namespace] segments := strings.Split(path, "/")
data := jet.VarMap{} manifestRequest, _ := regexp.MatchString(".*/manifests/[^/]*$", path)
data.Set("namespace", namespace) manifestDeleteRequest, _ := regexp.MatchString(".*/manifests/[^/]*/delete$", path)
data.Set("namespaces", a.client.Namespaces()) if manifestRequest {
data.Set("repos", repos) repoPath := strings.Join(segments[0:len(segments)-2], "/")
data.Set("tagCounts", a.client.TagCounts()) tagName := segments[len(segments)-1]
return a.viewTagInfo(c, repoPath, tagName)
return c.Render(http.StatusOK, "repositories.html", data) } else if manifestDeleteRequest {
} repoPath := strings.Join(segments[0:len(segments)-3], "/")
tagName := segments[len(segments)-2]
func (a *apiClient) viewTags(c echo.Context) error { return a.deleteTag(c, repoPath, tagName)
namespace := c.Param("namespace") } else {
repo := c.Param("repo") return a.listRepo(c, path)
repoPath := repo }
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
} }
func (a *apiClient) listRepo(c echo.Context, repoPath string) error {
tags := a.client.Tags(repoPath) tags := a.client.Tags(repoPath)
repos := a.client.RepositoriesList(true)
filterExpression := repoPath
if !strings.HasSuffix(repoPath, "/") && repoPath != "" {
filterExpression += "/"
}
matching_repos := registry.FilterStringSlice(repos, func(s string) bool {
return strings.HasPrefix(s, filterExpression) && len(s) > len(repoPath)
})
deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER"))
data := jet.VarMap{} data := jet.VarMap{}
data.Set("namespace", namespace) data.Set("repoPath", repoPath)
data.Set("repo", repo) data.Set("breadCrumbs", getBreadCrumbs(repoPath))
data.Set("tags", tags) data.Set("tags", tags)
data.Set("repos", matching_repos)
data.Set("deleteAllowed", deleteAllowed) data.Set("deleteAllowed", deleteAllowed)
data.Set("tagCounts", a.client.TagCounts())
repoPath, _ = url.PathUnescape(repoPath) repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath)) data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data) return c.Render(http.StatusOK, "list.html", data)
} }
func (a *apiClient) viewTagInfo(c echo.Context) error { func (a *apiClient) viewTagInfo(c echo.Context, repoPath string, tag string) error {
namespace := c.Param("namespace") repo := repoPath
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// Retrieve full image info from various versions of manifests // Retrieve full image info from various versions of manifests
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false) sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
sha256list, manifests := a.client.ManifestList(repoPath, tag) sha256list, manifests := a.client.ManifestList(repoPath, tag)
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 { if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", a.config.BasePath, repo))
} }
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String() created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
@ -277,7 +297,7 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
r["size"] = dSize r["size"] = dSize
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range". // Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" { if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"]) r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s">%s</a>`, a.config.BasePath, repo, r["digest"], r["digest"])
} }
} else { } else {
// Sub-image of the cache type. // Sub-image of the cache type.
@ -289,10 +309,10 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
// Populate template vars // Populate template vars
data := jet.VarMap{} data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("repo", repo) data.Set("repo", repo)
data.Set("tag", tag) data.Set("tag", tag)
data.Set("repoPath", repoPath) data.Set("repoPath", repoPath)
data.Set("breadCrumbs", getBreadCrumbs(repoPath))
data.Set("sha256", sha256) data.Set("sha256", sha256)
data.Set("imageSize", imageSize) data.Set("imageSize", imageSize)
data.Set("created", created) data.Set("created", created)
@ -305,20 +325,12 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
return c.Render(http.StatusOK, "tag_info.html", data) return c.Render(http.StatusOK, "tag_info.html", data)
} }
func (a *apiClient) deleteTag(c echo.Context) error { func (a *apiClient) deleteTag(c echo.Context, repoPath string, tag string) 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")) { if a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) {
a.client.DeleteTag(repoPath, tag) a.client.DeleteTag(repoPath, tag)
} }
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s", a.config.BasePath, repoPath))
} }
// checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users. // checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users.

View File

@ -5,7 +5,6 @@ import (
"crypto/tls" "crypto/tls"
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -27,7 +26,7 @@ type Client struct {
logger *logrus.Entry logger *logrus.Entry
mux sync.Mutex mux sync.Mutex
tokens map[string]string tokens map[string]string
repos map[string][]string repos []string
tagCounts map[string]int tagCounts map[string]int
authURL string authURL string
} }
@ -43,7 +42,7 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}), request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
logger: SetupLogging("registry.client"), logger: SetupLogging("registry.client"),
tokens: map[string]string{}, tokens: map[string]string{},
repos: map[string][]string{}, repos: []string{},
tagCounts: map[string]int{}, tagCounts: map[string]int{},
} }
resp, _, errs := c.request.Get(c.url+"/v2/"). resp, _, errs := c.request.Get(c.url+"/v2/").
@ -153,21 +152,7 @@ func (c *Client) callRegistry(uri, scope, manifestFormat string) (string, gorequ
return data, resp return data, resp
} }
// Namespaces list repo namespaces. func (c *Client) RepositoriesList(useCache bool) []string {
func (c *Client) Namespaces() []string {
namespaces := make([]string, 0, len(c.repos))
for k := range c.repos {
namespaces = append(namespaces, k)
}
if !ItemInSlice("library", namespaces) {
namespaces = append(namespaces, "library")
}
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. // Return from cache if available.
if len(c.repos) > 0 && useCache { if len(c.repos) > 0 && useCache {
return c.repos return c.repos
@ -179,7 +164,7 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$") linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
scope := "registry:catalog:*" scope := "registry:catalog:*"
uri := "/v2/_catalog" uri := "/v2/_catalog"
c.repos = map[string][]string{} c.repos = []string{}
for { for {
data, resp := c.callRegistry(uri, scope, "manifest.v2") data, resp := c.callRegistry(uri, scope, "manifest.v2")
if data == "" { if data == "" {
@ -187,14 +172,7 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
} }
for _, r := range gjson.Get(data, "repositories").Array() { for _, r := range gjson.Get(data, "repositories").Array() {
namespace := "library" c.repos = append(c.repos, r.String())
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)
} }
// pagination // pagination
@ -274,15 +252,9 @@ func (c *Client) CountTags(interval uint8) {
for { for {
start := time.Now() start := time.Now()
c.logger.Info("[CountTags] Calculating image tags...") c.logger.Info("[CountTags] Calculating image tags...")
catalog := c.Repositories(false) catalog := c.RepositoriesList(false)
for n, repos := range catalog { for _, repoPath := range catalog {
for _, r := range repos { c.tagCounts[repoPath] = len(c.Tags(repoPath))
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.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start)) c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start))
time.Sleep(time.Duration(interval) * time.Minute) time.Sleep(time.Duration(interval) * time.Minute)
@ -307,10 +279,7 @@ func (c *Client) DeleteTag(repo, tag string) {
if len(errs) > 0 { if len(errs) > 0 {
c.logger.Error(errs[0]) c.logger.Error(errs[0])
} else { } else {
// Returns 202 on success. if c.tagCounts[repo] > 0 {
if !strings.Contains(repo, "/") {
c.tagCounts["library/"+repo]--
} else {
c.tagCounts[repo]-- c.tagCounts[repo]--
} }
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status) c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)

View File

@ -58,3 +58,12 @@ func ItemInSlice(item string, slice []string) bool {
} }
return false return false
} }
func FilterStringSlice(ss []string, test func(string) bool) (ret []string) {
for _, s := range ss {
if test(s) {
ret = append(ret, s)
}
}
return
}

View File

@ -44,18 +44,10 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
dryRunText = "skipped" dryRunText = "skipped"
} }
logger.Info("Scanning registry for repositories, tags and their creation dates...") logger.Info("Scanning registry for repositories, tags and their creation dates...")
catalog := client.Repositories(true) catalog := client.RepositoriesList(true)
// catalog := map[string][]string{"library": []string{""}}
now := time.Now().UTC() now := time.Now().UTC()
repos := map[string]timeSlice{} repos := map[string]timeSlice{}
count := 0 for _, repo := range catalog {
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) tags := client.Tags(repo)
logger.Infof("[%s] scanning %d tags...", repo, len(tags)) logger.Infof("[%s] scanning %d tags...", repo, len(tags))
if len(tags) == 0 { if len(tags) == 0 {
@ -71,13 +63,12 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
repos[repo] = append(repos[repo], tagData{name: tag, created: created}) repos[repo] = append(repos[repo], tagData{name: tag, created: created})
} }
} }
}
logger.Infof("Scanned %d repositories.", count) logger.Infof("Scanned %d repositories.", len(catalog))
logger.Info("Filtering out tags for purging...") logger.Info("Filtering out tags for purging...")
purgeTags := map[string][]string{} purgeTags := map[string][]string{}
keepTags := map[string][]string{} keepTags := map[string][]string{}
count = 0 count := 0
for _, repo := range SortedMapKeys(repos) { for _, repo := range SortedMapKeys(repos) {
// Sort tags by "created" from newest to oldest. // Sort tags by "created" from newest to oldest.
sortedTags := make(timeSlice, 0, len(repos[repo])) sortedTags := make(timeSlice, 0, len(repos[repo]))

View File

@ -6,14 +6,18 @@
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
$('#datatable').DataTable({ $('#datatable').DataTable({
"pageLength": 10, "pageLength": 25,
"order": [[ 0, 'desc' ]], "order": [[ 0, 'desc' ]],
"stateSave": true, "stateSave": true,
columnDefs: [ columnDefs: [
{ type: 'natural', targets: 0 } { data: 'Type', visible: false, targets: 2 },
{ type: 'natural', targets: 0, width: "80%" }
], ],
"language": { "language": {
"emptyTable": "No tags in this repository." "emptyTable": "No entry."
},
rowGroup: {
dataSrc: 'Type'
} }
}) })
function populateConfirmation() { function populateConfirmation() {
@ -25,7 +29,6 @@
populateConfirmation() populateConfirmation()
$('#datatable').on('draw.dt', populateConfirmation) $('#datatable').on('draw.dt', populateConfirmation)
}); });
</script> </script>
{{end}} {{end}}
@ -33,27 +36,43 @@
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li> <li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}} {{range crumb := breadCrumbs}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li> <li><a href="{{ basePath }}/{{ crumb.path }}">{{ crumb.segment }}</a></li>
{{end}} {{end}}
<li class="active">{{ repo|url_decode }}</li>
</ol> </ol>
<table id="datatable" class="table table-striped table-bordered"> <table id="datatable" class="table table-striped table-bordered">
<thead bgcolor="#ddd"> <thead bgcolor="#ddd">
<tr> <tr>
<th>Tag Name</th> <th>Name</th>
<th>Tag count</th>
<th>Type</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range tag := tags}} {{range tag := tags}}
<tr> <tr>
<td> <td>
<a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a> <a href="{{ basePath }}/{{ repoPath }}/manifests/{{ tag }}">{{ tag }}</a>
{{if deleteAllowed}} {{if deleteAllowed}}
<a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a> <a href="{{ basePath }}/{{ repoPath }}/manifests/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
{{end}} {{end}}
</td> </td>
<td>n/a</td>
<td>Tags</td>
</tr>
{{end}}
{{range repo := repos}}
<tr>
<td>
{{noprefix_repo := repo[len(repoPath):]}}
{{if noprefix_repo[0:1] == "/"}}
{{noprefix_repo = noprefix_repo[1:]}}
{{end}}
<a href="{{ basePath }}/{{ repo }}">{{ noprefix_repo }}</a>
</td>
<td>{{ tagCounts[repo] }}</td>
<td>Repositories</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>

View File

@ -1,68 +0,0 @@
{{extends "base.html"}}
{{block head()}}
<script type="text/javascript">
$(document).ready(function() {
$('#namespace').on('change', function (e) {
window.location = '{{ basePath }}/' + this.value;
});
namespace = window.location.pathname;
namespace = namespace.replace("{{ basePath }}", "");
if (namespace == '/') {
namespace = 'library';
} else {
namespace = namespace.split('/')[1]
}
$('#namespace').val(namespace);
$('#datatable').DataTable({
"pageLength": 25,
"stateSave": true,
"language": {
"emptyTable": "No repositories in \"" + namespace + "\" namespace."
}
});
});
</script>
{{end}}
{{block body()}}
<div style="float: right">
<select id="namespace" class="form-control input-sm" style="height: 36px">
{{range namespace := namespaces}}
<option value="{{ namespace }}">{{ namespace }}</option>
{{end}}
</select>
</div>
<div style="float: right">
<ol class="breadcrumb">
<li class="active">Namespace</li>
</ol>
</div>
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
{{end}}
</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}}
{{if !isset(tagCounts[namespace+"/"+repo]) || (isset(tagCounts[namespace+"/"+repo]) && tagCounts[namespace+"/"+repo] > 0)}}
<tr>
<td><a href="{{ basePath }}/{{ namespace }}/{{ repo|url }}">{{ repo }}</a></td>
<td>{{ tagCounts[namespace+"/"+repo] }}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
{{end}}

View File

@ -5,11 +5,10 @@
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li> <li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}} {{range crumb := breadCrumbs}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li> <li><a href="{{ basePath }}/{{ crumb.path }}">{{ crumb.segment }}</a></li>
{{end}} {{end}}
<li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li> <li><a href="{{ basePath }}/{{ repoPath }}/manifests/{{ tag }}">{{ tag }}</a></li>
<li class="active">{{ tag }}</li>
</ol> </ol>
<h4>Image Details</h4> <h4>Image Details</h4>