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
data/registry_events.db
vendor/
*.swp

View File

@ -6,8 +6,11 @@ RUN apk update && \
WORKDIR /opt/src
ADD events events
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 && \
go build -o /opt/docker-registry-ui *.go

112
main.go
View File

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

View File

@ -5,7 +5,6 @@ import (
"crypto/tls"
"fmt"
"regexp"
"sort"
"strings"
"sync"
"time"
@ -27,7 +26,7 @@ type Client struct {
logger *logrus.Entry
mux sync.Mutex
tokens map[string]string
repos map[string][]string
repos []string
tagCounts map[string]int
authURL string
}
@ -43,7 +42,7 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
logger: SetupLogging("registry.client"),
tokens: map[string]string{},
repos: map[string][]string{},
repos: []string{},
tagCounts: map[string]int{},
}
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
}
// 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)
}
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 {
func (c *Client) RepositoriesList(useCache bool) []string {
// Return from cache if available.
if len(c.repos) > 0 && useCache {
return c.repos
@ -179,7 +164,7 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
scope := "registry:catalog:*"
uri := "/v2/_catalog"
c.repos = map[string][]string{}
c.repos = []string{}
for {
data, resp := c.callRegistry(uri, scope, "manifest.v2")
if data == "" {
@ -187,14 +172,7 @@ func (c *Client) Repositories(useCache bool) 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)
c.repos = append(c.repos, r.String())
}
// pagination
@ -274,15 +252,9 @@ func (c *Client) CountTags(interval uint8) {
for {
start := time.Now()
c.logger.Info("[CountTags] Calculating image tags...")
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))
}
catalog := c.RepositoriesList(false)
for _, repoPath := range catalog {
c.tagCounts[repoPath] = len(c.Tags(repoPath))
}
c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start))
time.Sleep(time.Duration(interval) * time.Minute)
@ -307,10 +279,7 @@ func (c *Client) DeleteTag(repo, tag string) {
if len(errs) > 0 {
c.logger.Error(errs[0])
} else {
// Returns 202 on success.
if !strings.Contains(repo, "/") {
c.tagCounts["library/"+repo]--
} else {
if c.tagCounts[repo] > 0 {
c.tagCounts[repo]--
}
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
}
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,40 +44,31 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
dryRunText = "skipped"
}
logger.Info("Scanning registry for repositories, tags and their creation dates...")
catalog := client.Repositories(true)
// catalog := map[string][]string{"library": []string{""}}
catalog := client.RepositoriesList(true)
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 {
for _, repo := range catalog {
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] missing manifest v1 for tag %s", repo, tag)
continue
}
for _, tag := range tags {
_, infoV1, _ := client.TagInfo(repo, tag, true)
if infoV1 == "" {
logger.Errorf("[%s] missing manifest v1 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})
}
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.Infof("Scanned %d repositories.", len(catalog))
logger.Info("Filtering out tags for purging...")
purgeTags := map[string][]string{}
keepTags := map[string][]string{}
count = 0
count := 0
for _, repo := range SortedMapKeys(repos) {
// Sort tags by "created" from newest to oldest.
sortedTags := make(timeSlice, 0, len(repos[repo]))

View File

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