37 Commits
0.7.3 ... 0.9.4

Author SHA1 Message Date
Roman Vynar
31b16bb17a Merge branch 'master' of github.com:Quiq/docker-registry-ui 2022-04-06 16:48:04 +03:00
Roman Vynar
a1bef247e0 Bump version, upgrade dependencies 2022-04-06 16:47:52 +03:00
jonny08152
905e8f793b use temporary map to prevent UI disruption during tags calculation (#60) 2021-12-22 11:07:31 +02:00
Roman Vynar
19d15065ed Bump version 2021-04-26 16:41:14 +03:00
Roman Vynar
811c9d7da0 Update dependencies 2021-04-26 16:36:18 +03:00
Steven Beverly
a84e06443a Support deleting manifest.list.v2 (#55)
Co-authored-by: Steven Beverly <steven@rigel.gopes>
2021-04-26 15:48:55 +03:00
Roman Vynar
828a5b72e3 Update changelog 2020-07-10 16:14:39 +03:00
Roman Vynar
2d3e770e6f Bump go version and dependencies 2020-07-10 15:43:01 +03:00
Roman Vynar
88964cb46e Use deffault logging level for PurgeOldTags() 2020-07-10 15:42:44 +03:00
Roman Vynar
c022edba0f Update README and screenshots. 2020-02-20 18:44:42 +02:00
Roman Vynar
f9899cb785 Minor amendments for the tag info page to account the cache type of sub-image. 2020-02-20 14:53:12 +02:00
Roman Vynar
e1cd96ef12 Update changelog 2020-02-19 12:42:22 +02:00
Roman Vynar
65c9978bd1 Compatibility fix with docker_auth v1.5.0 2020-02-19 12:36:55 +02:00
Roman Vynar
b6398fa33c Hide repositories with 0 tags count 2020-02-19 11:29:54 +02:00
Roman Vynar
67d82c7d59 Amend tag info page, change logging.
* Amend representation of the tag info page
* Change logging library, add "-log-level" argument and put most of the logging into DEBUG mode
2020-02-18 23:31:56 +02:00
Roman Vynar
dc7b2e42fc Update the link of Quiq website 2020-02-17 13:23:58 +02:00
Roman Vynar
41e74f70a2 Upgrade Go and dependencies, support custom TZ, fix initial ownership of sqlite db dir.
* Upgrade Go version to 1.13.7, alpine to 3.11 and other dependencies.
* You can define timezone when running container by adding `TZ` env var, e.g. "-e TZ=America/Los_Angeles"
  (thanks to @gminog).
* Fix initial ownership of /opt/data dir in Dockerfile.
2020-02-17 13:13:53 +02:00
Christoph Honal
905e760956 Support V2 Manifest Lists (#45)
* Support V2 Manifest Lists

* Fix breadcrumb navigation for digest tags

* Support BuildX cache images & display extended attributes of manifests
2020-02-17 12:11:37 +02:00
Roman Vynar
ee38e35ba6 Fix tests 2019-07-30 09:18:46 +03:00
Roman Vynar
3d90f7b176 Release 0.8.2 2019-07-30 09:06:48 +03:00
Roman Vynar
3125554074 Bump go version 2019-07-30 09:02:36 +03:00
Roman Vynar
d5b6669eee Add event_deletion_enabled option to the config, useful for master-master/cluster setups 2019-07-30 09:02:25 +03:00
하광일 / TC / CE
fe0f3e28e8 Generate from response body if no Docker-Content-Digest header (#41) 2019-07-29 10:40:25 +03:00
Roman Vynar
5bce4ad9c6 Better breadcrumb 2019-02-20 16:44:35 +02:00
Roman Vynar
7151e7b269 Add favicon 2019-02-20 10:22:52 +02:00
Roman Vynar
4b6870cc35 Add favicon 2019-02-20 10:19:57 +02:00
Roman Vynar
a7f824f12c Release 0.8.0 2019-02-19 17:07:07 +02:00
Roman Vynar
fea503c96b Add glyphicons to static files 2019-02-19 16:42:11 +02:00
Roman Vynar
c3cc11f8d4 Move line 2019-02-19 16:41:46 +02:00
Yuhi Ishikura
36187651e0 Implemented pagination of repositories (#14)
* Change the return value of Client.callRegistry.

* Supported pagination of `repositories`.
2019-02-19 16:40:45 +02:00
Roman Vynar
1b84570645 Put all static files in the container 2019-02-19 15:06:31 +02:00
Roman Vynar
d46605fc06 Bump go, alpine, echo versions 2019-02-19 14:30:50 +02:00
Roman Vynar
1f6801acfb Bump go to 1.11.4 and all deps 2018-12-18 16:28:21 +02:00
Roman Vynar
359305046c Merge pull request #28 from maximsnezhkov/fix-data-too-long-error
Modify sql table creation to avoid 'data too long' error
2018-12-18 10:30:37 +02:00
Maxim Snezhkov
2cb2802973 Modify sql table creation to avoid 'data too long' error 2018-12-18 08:51:45 +07:00
Roman Vynar
35aba0dfc3 Bump Go version 2018-12-13 11:55:55 +02:00
Roman Vynar
32d8ab87bd Switch to Go 1.11 and Go Modules to track dependencies 2018-10-30 23:30:55 +02:00
36 changed files with 899 additions and 260 deletions

View File

@@ -1,5 +1,59 @@
## Changelog
### 0.9.4 (2022-04-06)
* Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies.
* Build docker image with ARM support.
### 0.9.3 (2021-04-26)
* Upgrade Go version to 1.16.3, alpine to 3.13 and other dependencies.
* Support deletion of manifest lists.
### 0.9.2 (2020-07-10)
* Upgrade Go version to 1.14.4, alpine to 3.12 and other dependencies.
* Enable default logging for purge tags task.
### 0.9.1 (2020-02-20)
* Minor amendments for the tag info page to account the cache type of sub-image.
### 0.9.0 (2020-02-19)
* Upgrade Go version to 1.13.7, alpine to 3.11 and other dependencies.
* Support Manifest List v2. This enables the proper display of multi-arch images,
such as those generated by Docker BuildX or manually (thanks to Christoph Honal @StarGate01).
So now we support the following formats: Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2
and all their confusing combinations.
* Amend representation of the tag info page.
* Change logging library, add "-log-level" argument and put most of the logging into DEBUG mode.
* You can define timezone when running container by adding `TZ` env var, e.g. "-e TZ=America/Los_Angeles"
(thanks to @gminog).
* Fix initial ownership of /opt/data dir in Dockerfile.
* Hide repositories with 0 tags count.
* Compatibility fix with docker_auth v1.5.0.
### 0.8.2 (2019-07-30)
* Add event_deletion_enabled option to the config, useful for master-master/cluster setups.
* Generate SHA256 from response body if no Docker-Content-Digest header is present, e.g. with AWS ECR.
* Bump go version.
### 0.8.1 (2019-02-20)
* Add favicon.
### 0.8.0 (2019-02-19)
* Use go 1.11.5, alpine 3.9, echo 3.3.10.
* Put all static files to the docker image instead of loading from CDN.
* Now discover more than 100 repositories (thanks to Yuhi Ishikura @uphy).
### 0.7.4 (2018-10-30)
* Switch to Go 1.11 and Go Modules to track dependencies.
### 0.7.3 (2018-08-14)
* Add `registry_password_file` option to the config file.

View File

@@ -1,28 +1,23 @@
FROM golang:1.10.3-alpine as builder
ENV GOPATH /opt
FROM golang:1.18.0-alpine3.15 as builder
RUN apk update && \
apk add ca-certificates git build-base && \
go get github.com/Masterminds/glide
apk add ca-certificates git bash gcc musl-dev
ADD glide.* /opt/src/github.com/quiq/docker-registry-ui/
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
/opt/bin/glide install
WORKDIR /opt/src
ADD events events
ADD registry registry
ADD *.go go.mod go.sum ./
ADD events /opt/src/github.com/quiq/docker-registry-ui/events
ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry
ADD *.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
RUN go test -v ./registry && \
go build -o /opt/docker-registry-ui *.go
FROM alpine:3.8
FROM alpine:3.15
WORKDIR /opt
RUN apk add --no-cache ca-certificates && \
mkdir /opt/data
RUN apk add --no-cache ca-certificates tzdata && \
mkdir /opt/data && \
chown nobody /opt/data
ADD templates /opt/templates
ADD static /opt/static

View File

@@ -1,4 +1,4 @@
Copyright 2017-2018 Quiq Inc.
Copyright 2017-2020 Quiq Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

7
Makefile Executable file
View File

@@ -0,0 +1,7 @@
IMAGE=quiq/docker-registry-ui
VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go`
.DEFAULT: buildx
buildx:
@docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push .

View File

@@ -4,9 +4,11 @@
### Overview
* Web UI for Docker Registry 2.6+
* Browse repositories and tags
* Display Docker image details by layers including both manifests v1 and v2
* Web UI for Docker Registry
* Browse namespaces, repositories and tags
* Display image details by layers
* Display sub-images of multi-arch or cache type of image
* Support Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 and their confusing combinations
* 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
@@ -36,8 +38,14 @@ To preserve sqlite db file with event notifications data, add to the command:
-v /local/data:/opt/data
Ensure /local/data is owner by nobody (alpine user id is 65534).
You can also run the container with `--read-only` option, however when using using event listener functionality
you need to ensure the sqlite db can be written, i.e. mount a folder as listed above.
you need to ensure the sqlite db can be written, i.e. mount a folder as listed above (rw mode).
To run with a custom TZ:
-e TZ=America/Los_Angeles
## Configure event listener on Docker Registry
@@ -104,8 +112,30 @@ Note, the cron schedule format includes seconds! See https://godoc.org/github.co
To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`.
### About Docker image formats...
Docker image formats and their confusing combinations as supported by this UI:
* Manifest v2 schema 1 only: older format, e.g. created with Docker 1.9.
* Manifest v2 schema 1 + Manifest v2 schema 2: current format of a single image, the image history are coming from schema 1, should be referenced by repo:tag name.
* Manifest v2 schema 1 + Manifest List v2 schema 2: multi-arch image format containing digests of sub-images, the image history are coming from schema 1 (no idea from what sub-image it was picked up when created), should be referenced by repo:tag name.
* Manifest v2 schema 2: current image format referenced by its digest sha256, no image history.
* Manifest List v2 schema 2: multi-arch image referenced by its digest sha256 or cache image referenced by tag name, no image history.
### Screenshots
Repository list / home page:
![image](screenshots/1.png)
Repository tag list:
![image](screenshots/2.png)
Tag info page:
![image](screenshots/3.png)
Event log page:
![image](screenshots/4.png)

View File

@@ -12,7 +12,7 @@ verify_tls: true
# 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.
# When the registry_password_file entry is used, the password can be passed as a docker secret
# When the registry_password_file entry is used, the password can be passed as a docker secret
# and read from file. This overides the registry_password entry.
registry_username: user
registry_password: pass
@@ -30,6 +30,10 @@ event_database_location: data/registry_events.db
# event_database_driver: mysql
# event_database_location: user:password@tcp(localhost:3306)/docker_events
# You can disable event deletion on some hosts when you are running docker-registry on master-master or
# cluster setup to avoid deadlocks or replication break.
event_deletion_enabled: True
# Cache refresh interval in minutes.
# How long to cache repository list and tag counts.
cache_refresh_interval: 10

View File

@@ -8,8 +8,9 @@ import (
"os"
"strings"
"github.com/hhkbp2/go-logging"
"github.com/quiq/docker-registry-ui/registry"
"github.com/sirupsen/logrus"
// 🐒 patching of "database/sql".
_ "github.com/go-sql-driver/mysql"
_ "github.com/mattn/go-sqlite3"
@@ -20,7 +21,7 @@ const (
schemaSQLite = `
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action CHAR(4) NULL,
action CHAR(5) NULL,
repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL,
@@ -35,7 +36,8 @@ type EventListener struct {
databaseDriver string
databaseLocation string
retention int
logger logging.Logger
eventDeletion bool
logger *logrus.Entry
}
type eventData struct {
@@ -54,11 +56,12 @@ type EventRow struct {
}
// NewEventListener initialize EventListener.
func NewEventListener(databaseDriver, databaseLocation string, retention int) *EventListener {
func NewEventListener(databaseDriver, databaseLocation string, retention int, eventDeletion bool) *EventListener {
return &EventListener{
databaseDriver: databaseDriver,
databaseLocation: databaseLocation,
retention: retention,
eventDeletion: eventDeletion,
logger: registry.SetupLogging("events.event_listener"),
}
}
@@ -74,7 +77,7 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
e.logger.Debugf("Received event: %+v", t)
j, _ := json.Marshal(t)
db, err := e.getDababaseHandler()
db, err := e.getDatabaseHandler()
if err != nil {
e.logger.Error(err)
return
@@ -112,6 +115,9 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
}
// Purge old records.
if !e.eventDeletion {
return
}
var res sql.Result
if e.databaseDriver == "mysql" {
stmt, _ := db.Prepare("DELETE FROM events WHERE created < DATE_SUB(NOW(), INTERVAL ? DAY)")
@@ -128,7 +134,7 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
func (e *EventListener) GetEvents(repository string) []EventRow {
var events []EventRow
db, err := e.getDababaseHandler()
db, err := e.getDatabaseHandler()
if err != nil {
e.logger.Error(err)
return events
@@ -154,7 +160,7 @@ func (e *EventListener) GetEvents(repository string) []EventRow {
return events
}
func (e *EventListener) getDababaseHandler() (*sql.DB, error) {
func (e *EventListener) getDatabaseHandler() (*sql.DB, error) {
firstRun := false
schema := schemaSQLite
if e.databaseDriver == "sqlite3" {

80
glide.lock generated
View File

@@ -1,80 +0,0 @@
hash: fea96c473a02b07acc1d600ee0f71c6a5143f34e5eb04a4c5e3e14378fca46f0
updated: 2018-06-09T09:03:51.972089+09:00
imports:
- name: github.com/CloudyKit/fastprinter
version: 74b38d55f37af5d6c05ca11147d616b613a3420e
- name: github.com/CloudyKit/jet
version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe
- name: github.com/dgrijalva/jwt-go
version: 06ea1031745cb8b3dab3f6a236daf2b0aa468b7e
- name: github.com/go-sql-driver/mysql
version: 64db0f7ebe171b596aa9b26f39a79f7413a3b617
- name: github.com/hhkbp2/go-logging
version: 377ba05d98977baa2a0d9e13f13aac2f3a47ac4d
- name: github.com/hhkbp2/go-strftime
version: d82166ec6782f870431668391c2e321069632fe7
- name: github.com/labstack/echo
version: 6d227dfea4d2e52cb76856120b3c17f758139b4e
subpackages:
- middleware
- name: github.com/labstack/gommon
version: 0a22a0df01a7c84944c607e8a6e91cfe421ea7ed
subpackages:
- bytes
- color
- log
- random
- name: github.com/mattn/go-colorable
version: efa589957cd060542a26d2dd7832fd6a6c6c3ade
- name: github.com/mattn/go-isatty
version: 6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c
- name: github.com/mattn/go-sqlite3
version: 323a32be5a2421b8c7087225079c6c900ec397cd
- name: github.com/moul/http2curl
version: 9ac6cf4d929b2fa8fd2d2e6dec5bb0feb4f4911d
- name: github.com/parnurzeal/gorequest
version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3
- name: github.com/pkg/errors
version: 816c9085562cd7ee03e7f8188a1cfd942858cded
- name: github.com/robfig/cron
version: b41be1df696709bb6395fe435af20370037c0b4c
- name: github.com/tidwall/gjson
version: 01f00f129617a6fe98941fb920d6c760241b54d2
- name: github.com/tidwall/match
version: 1731857f09b1f38450e2c12409748407822dc6be
- name: github.com/valyala/bytebufferpool
version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7
- name: github.com/valyala/fasttemplate
version: dcecefd839c4193db0d35b88ec65b4c12d360ab0
- name: golang.org/x/crypto
version: 1a580b3eff7814fc9b40602fd35256c63b50f491
subpackages:
- acme
- acme/autocert
- name: golang.org/x/net
version: dfa909b99c79129e1100513e5cd36307665e5723
subpackages:
- publicsuffix
- name: golang.org/x/sys
version: 7c87d13f8e835d2fb3a70a2912c811ed0c1d241b
subpackages:
- unix
- name: google.golang.org/appengine
version: b1f26356af11148e710935ed1ac8a7f5702c7612
subpackages:
- cloudsql
- name: gopkg.in/yaml.v2
version: 5420a8b6744d3b0345ab293f6fcba19c978f1183
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

View File

@@ -1,23 +0,0 @@
package: github.com/quiq/docker-registry-ui
import:
- package: github.com/CloudyKit/jet
version: v2.1.2
- package: github.com/labstack/echo
version: v3.3.5
subpackages:
- middleware
- package: github.com/parnurzeal/gorequest
version: v0.2.15
- package: github.com/hhkbp2/go-logging
- package: github.com/tidwall/gjson
version: v1.1.0
- package: github.com/mattn/go-sqlite3
version: 1.7.0
- package: github.com/go-sql-driver/mysql
- package: github.com/robfig/cron
version: ~1.1.0
testImport:
- package: github.com/smartystreets/goconvey
version: 1.6.2
subpackages:
- convey

20
go.mod Normal file
View File

@@ -0,0 +1,20 @@
module github.com/quiq/docker-registry-ui
require (
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e // indirect
github.com/go-sql-driver/mysql v1.6.0
github.com/labstack/echo/v4 v4.7.2
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/parnurzeal/gorequest v0.2.16
github.com/pkg/errors v0.9.1 // indirect
github.com/robfig/cron v1.2.0
github.com/sirupsen/logrus v1.8.1
github.com/smartystreets/goconvey v1.7.2
github.com/tidwall/gjson v1.14.0
gopkg.in/yaml.v2 v2.4.0
moul.io/http2curl v1.0.0 // indirect
)
go 1.15

93
go.sum Normal file
View File

@@ -0,0 +1,93 @@
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible h1:rZgFj+Gtf3NMi/U5FvCvhzaxzW/TaPYgUYx3bAPz9DE=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e h1:8dhROE/dIrz8nOJQjah6LG37QfL8fZhQTp1RDAjuNpQ=
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI=
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=

99
main.go
View File

@@ -10,11 +10,12 @@ import (
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo"
"github.com/labstack/echo/middleware"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/quiq/docker-registry-ui/events"
"github.com/quiq/docker-registry-ui/registry"
"github.com/robfig/cron"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"gopkg.in/yaml.v2"
)
@@ -31,6 +32,7 @@ type configData struct {
EventRetentionDays int `yaml:"event_retention_days"`
EventDatabaseDriver string `yaml:"event_database_driver"`
EventDatabaseLocation string `yaml:"event_database_location"`
EventDeletionEnabled bool `yaml:"event_deletion_enabled"`
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
AnyoneCanDelete bool `yaml:"anyone_can_delete"`
Admins []string `yaml:"admins"`
@@ -52,16 +54,23 @@ type apiClient struct {
func main() {
var (
a apiClient
configFile string
purgeTags bool
purgeDryRun bool
a apiClient
configFile, loggingLevel string
purgeTags, purgeDryRun bool
)
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
flag.StringVar(&loggingLevel, "log-level", "info", "logging level")
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()
if loggingLevel != "info" {
if level, err := logrus.ParseLevel(loggingLevel); err == nil {
logrus.SetLevel(level)
}
}
// Read config file.
if _, err := os.Stat(configFile); os.IsNotExist(err) {
panic(err)
@@ -128,13 +137,16 @@ func main() {
if a.config.EventDatabaseDriver != "sqlite3" && a.config.EventDatabaseDriver != "mysql" {
panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql"))
}
a.eventListener = events.NewEventListener(a.config.EventDatabaseDriver, a.config.EventDatabaseLocation, a.config.EventRetentionDays)
a.eventListener = events.NewEventListener(
a.config.EventDatabaseDriver, a.config.EventDatabaseLocation, a.config.EventRetentionDays, a.config.EventDeletionEnabled,
)
// Template engine init.
e := echo.New()
e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath)
// Web routes.
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)
@@ -205,11 +217,35 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// Retrieve full image info from various versions of manifests
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
if infoV1 == "" || infoV2 == "" {
sha256list, manifests := a.client.ManifestList(repoPath, tag)
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
isDigest := strings.HasPrefix(tag, "sha256:")
if len(manifests) > 0 {
sha256 = sha256list
}
// Gather layers v2
var layersV2 []map[string]gjson.Result
for _, s := range gjson.Get(infoV2, "layers").Array() {
layersV2 = append(layersV2, s.Map())
}
// Gather layers v1
var layersV1 []map[string]interface{}
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
// Sort key in the map to show the ordered on UI.
m["ordered_keys"] = registry.SortedMapKeys(m)
layersV1 = append(layersV1, m)
}
// Count image size
var imageSize int64
if gjson.Get(infoV2, "layers").Exists() {
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
@@ -221,35 +257,50 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
}
}
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)
}
// Count layers
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
// Gather sub-image info of multi-arch or cache image
var digestList []map[string]interface{}
for _, s := range manifests {
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
// Sub-image of the specific arch.
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
var dSize int64
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
dSize = dSize + d.Int()
}
r["size"] = dSize
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
}
} else {
// Sub-image of the cache type.
r["size"] = s.Get("size").Int()
}
r["ordered_keys"] = registry.SortedMapKeys(r)
digestList = append(digestList, r)
}
// Populate template vars
data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tag", tag)
data.Set("repoPath", repoPath)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
data.Set("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("created", created)
data.Set("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
data.Set("isDigest", isDigest)
data.Set("digestList", digestList)
return c.Render(http.StatusOK, "tag_info.html", data)
}

View File

@@ -1,6 +1,7 @@
package registry
import (
"crypto"
"crypto/tls"
"fmt"
"regexp"
@@ -9,11 +10,13 @@ import (
"sync"
"time"
"github.com/hhkbp2/go-logging"
"github.com/parnurzeal/gorequest"
"github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
const userAgent = "docker-registry-ui"
// Client main class.
type Client struct {
url string
@@ -21,7 +24,7 @@ type Client struct {
username string
password string
request *gorequest.SuperAgent
logger logging.Logger
logger *logrus.Entry
mux sync.Mutex
tokens map[string]string
repos map[string][]string
@@ -43,7 +46,8 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
repos: map[string][]string{},
tagCounts: map[string]int{},
}
resp, _, errs := c.request.Get(c.url+"/v2/").Set("User-Agent", "docker-registry-ui").End()
resp, _, errs := c.request.Get(c.url+"/v2/").
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return nil
@@ -81,14 +85,18 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
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()
resp, _, _ := c.request.Get(c.url+"/v2/").
Set("Authorization", fmt.Sprintf("Bearer %s", token)).
Set("User-Agent", userAgent).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()
resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).
SetBasicAuth(c.username, c.password).
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return ""
@@ -98,48 +106,51 @@ func (c *Client) getToken(scope string) string {
return ""
}
c.tokens[scope] = gjson.Get(data, "token").String()
c.logger.Info("Received new token for scope ", scope)
token := gjson.Get(data, "token").String()
// Fix for docker_auth v1.5.0 only
if token == "" {
token = gjson.Get(data, "access_token").String()
}
c.tokens[scope] = token
c.logger.Debugf("Received new token for scope %s", 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)
// callRegistry make an HTTP request to retrieve data from Docker registry.
func (c *Client) callRegistry(uri, scope, manifestFormat string) (string, gorequest.Response) {
acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.%s+json", manifestFormat)
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()
resp, data, errs := c.request.Get(c.url+uri).
Set("Accept", acceptHeader).
Set("Authorization", authHeader).
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return "", ""
return "", resp
}
c.logger.Info("GET ", uri, " ", resp.Status)
c.logger.Debugf("GET %s %s", uri, resp.Status)
// Returns 404 when no tags in the repo.
if resp.StatusCode != 200 {
return "", ""
return "", resp
}
// Ensure Docker-Content-Digest header is present as we use it in various places.
// The header is probably in AWS ECR case.
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 "", ""
if digest == "" {
// Try to get digest from body instead, should be equal to what would be presented in Docker-Content-Digest.
h := crypto.SHA256.New()
h.Write([]byte(data))
resp.Header.Set("Docker-Content-Digest", fmt.Sprintf("sha256:%x", h.Sum(nil)))
}
return data, digest
return data, resp
}
// Namespaces list repo namespaces.
@@ -164,31 +175,48 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
c.mux.Lock()
defer c.mux.Unlock()
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
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]
uri := "/v2/_catalog"
tmp := map[string][]string{}
for {
data, resp := c.callRegistry(uri, scope, "manifest.v2")
if data == "" {
c.repos = tmp
return c.repos
}
c.repos[namespace] = append(c.repos[namespace], repo)
}
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]
}
tmp[namespace] = append(tmp[namespace], repo)
}
// pagination
linkHeader := resp.Header.Get("Link")
link := linkRegexp.FindStringSubmatch(linkHeader)
if len(link) == 2 {
// update uri and query next page
uri = link[1]
} else {
// no more pages
break
}
}
c.repos = tmp
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)
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, "manifest.v2")
var tags []string
for _, t := range gjson.Get(data, "tags").Array() {
tags = append(tags, t.String())
@@ -196,24 +224,45 @@ func (c *Client) Tags(repo string) []string {
return tags
}
// TagInfo get image info for the repo tag.
func (c *Client) TagInfo(repo, tag string, v1only bool) (rsha256, rinfoV1, rinfoV2 string) {
// ManifestList gets manifest list entries for a tag for the repo.
func (c *Client) ManifestList(repo, tag string) (string, []gjson.Result) {
scope := fmt.Sprintf("repository:%s:*", repo)
infoV1, _ := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 1, false)
if infoV1 == "" {
return "", "", ""
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
// If manifest.list.v2 does not exist because it's a normal image,
// the registry returns manifest.v1 or manifest.v2 if requested by sha256.
info, resp := c.callRegistry(uri, scope, "manifest.list.v2")
digest := resp.Header.Get("Docker-Content-Digest")
sha256 := ""
if digest != "" {
sha256 = digest[7:]
}
c.logger.Debugf(`Received manifest.list.v2 with sha256 "%s" from %s: %s`, sha256, uri, info)
return sha256, gjson.Get(info, "manifests").Array()
}
if v1only {
// TagInfo get image info for the repo tag or digest sha256.
func (c *Client) TagInfo(repo, tag string, v1only bool) (string, string, string) {
scope := fmt.Sprintf("repository:%s:*", repo)
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
// Note, if manifest.v1 does not exist because the image is requested by sha256,
// the registry returns manifest.v2 instead or manifest.list.v2 if it's the manifest list!
infoV1, _ := c.callRegistry(uri, scope, "manifest.v1")
c.logger.Debugf("Received manifest.v1 from %s: %s", uri, infoV1)
if infoV1 == "" || v1only {
return "", infoV1, ""
}
infoV2, digest := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, false)
// Note, if manifest.v2 does not exist because the image is in the older format (Docker 1.9),
// the registry returns manifest.v1 instead or manifest.list.v2 if it's the manifest list requested by sha256!
infoV2, resp := c.callRegistry(uri, scope, "manifest.v2")
c.logger.Debugf("Received manifest.v2 from %s: %s", uri, infoV2)
digest := resp.Header.Get("Docker-Content-Digest")
if infoV2 == "" || digest == "" {
return "", "", ""
}
sha256 := digest[7:]
c.logger.Debugf("sha256 for %s/%s is %s", repo, tag, sha256)
return sha256, infoV1, infoV2
}
@@ -225,7 +274,8 @@ func (c *Client) TagCounts() map[string]int {
// CountTags count repository tags in background regularly.
func (c *Client) CountTags(interval uint8) {
for {
c.logger.Info("Calculating tags in background...")
start := time.Now()
c.logger.Info("[CountTags] Calculating image tags...")
catalog := c.Repositories(false)
for n, repos := range catalog {
for _, r := range repos {
@@ -236,7 +286,7 @@ func (c *Client) CountTags(interval uint8) {
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
}
}
c.logger.Info("Tags calculation complete.")
c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start))
time.Sleep(time.Duration(interval) * time.Minute)
}
}
@@ -244,5 +294,31 @@ func (c *Client) CountTags(interval uint8) {
// 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)
// Get sha256 digest for tag.
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2")
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" {
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2")
}
// Delete by manifest digest reference.
authHeader := ""
if c.authURL != "" {
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
}
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest"))
resp, _, errs := c.request.Delete(c.url+uri).
Set("Authorization", authHeader).
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
} else {
// Returns 202 on success.
if !strings.Contains(repo, "/") {
c.tagCounts["library/"+repo]--
} else {
c.tagCounts[repo]--
}
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)
}
}

View File

@@ -2,23 +2,24 @@ package registry
import (
"fmt"
"os"
"reflect"
"sort"
"time"
"github.com/hhkbp2/go-logging"
"github.com/sirupsen/logrus"
)
// 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
// SetupLogging setup logger
func SetupLogging(name string) *logrus.Entry {
logrus.SetFormatter(&logrus.TextFormatter{
TimestampFormat: time.RFC3339,
FullTimestamp: true,
})
// Output to stdout instead of the default stderr.
logrus.SetOutput(os.Stdout)
return logrus.WithFields(logrus.Fields{"logger": name})
}
// SortedMapKeys sort keys of the map where values can be of any type.
@@ -40,7 +41,12 @@ func PrettySize(size float64) string {
size = size / 1024
i = i + 1
}
return fmt.Sprintf("%.*f %s", 0, size, units[i])
// Format decimals as follow: 0 B, 0 KB, 0.0 MB, 0.00 GB
decimals := i - 1
if decimals < 0 {
decimals = 0
}
return fmt.Sprintf("%.*f %s", decimals, size, units[i])
}
// ItemInSlice check if item is an element of slice

View File

@@ -14,14 +14,14 @@ func TestSortedMapKeys(t *testing.T) {
"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()}},
"zoo": []tagData{{name: "1", created: time.Now()}},
"abc": []tagData{{name: "1", created: time.Now()}},
"foo": []tagData{{name: "1", created: time.Now()}},
}
c := map[string][]string{
"zoo": []string{"1", "2"},
"foo": []string{"1", "2"},
"abc": []string{"1", "2"},
"zoo": {"1", "2"},
"foo": {"1", "2"},
"abc": {"1", "2"},
}
expect := []string{"abc", "foo", "zoo"}
convey.Convey("Sort map keys", t, func() {
@@ -37,8 +37,8 @@ func TestPrettySize(t *testing.T) {
123: "123 B",
23123: "23 KB",
23923: "23 KB",
723425120: "690 MB",
8534241213: "8 GB",
723425120: "689.9 MB",
8534241213: "7.95 GB",
}
for key, val := range input {
convey.So(PrettySize(key), convey.ShouldEqual, val)

View File

@@ -5,7 +5,6 @@ import (
"sort"
"time"
"github.com/hhkbp2/go-logging"
"github.com/tidwall/gjson"
)
@@ -35,9 +34,6 @@ func (p timeSlice) Swap(i, j int) {
// 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.")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 174 KiB

BIN
screenshots/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
screenshots/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

4
static/README.md Normal file
View File

@@ -0,0 +1,4 @@
https://cdn.datatables.net/r/bs-3.3.5/jq-2.1.4,dt-1.10.8/datatables.min.css
https://cdn.datatables.net/r/bs-3.3.5/jqc-1.11.3,dt-1.10.8/datatables.min.js
https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js

0
static/bootstrap-confirmation.min.js vendored Executable file → Normal file
View File

21
static/datatables.min.css vendored Normal file

File diff suppressed because one or more lines are too long

204
static/datatables.min.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

128
static/sorting_natural.js Normal file
View File

@@ -0,0 +1,128 @@
/**
* Data can often be a complicated mix of numbers and letters (file names
* are a common example) and sorting them in a natural manner is quite a
* difficult problem.
*
* Fortunately a deal of work has already been done in this area by other
* authors - the following plug-in uses the [naturalSort() function by Jim
* Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support) to provide natural sorting in DataTables.
*
* @name Natural sorting
* @summary Sort data with a mix of numbers and letters _naturally_.
* @author [Jim Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support)
* @author [Michael Buehler] (https://github.com/AnimusMachina)
*
* @example
* $('#example').dataTable( {
* columnDefs: [
* { type: 'natural', targets: 0 }
* ]
* } );
*
* Html can be stripped from sorting by using 'natural-nohtml' such as
*
* $('#example').dataTable( {
* columnDefs: [
* { type: 'natural-nohtml', targets: 0 }
* ]
* } );
*
*/
(function() {
/*
* Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license
* Author: Jim Palmer (based on chunking idea from Dave Koelle)
* Contributors: Mike Grier (mgrier.com), Clint Priest, Kyle Adams, guillermo
* See: http://js-naturalsort.googlecode.com/svn/trunk/naturalSort.js
*/
function naturalSort (a, b, html) {
var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?%?$|^0x[0-9a-f]+$|[0-9]+)/gi,
sre = /(^[ ]*|[ ]*$)/g,
dre = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/,
hre = /^0x[0-9a-f]+$/i,
ore = /^0/,
htmre = /(<([^>]+)>)/ig,
// convert all to strings and trim()
x = a.toString().replace(sre, '') || '',
y = b.toString().replace(sre, '') || '';
// remove html from strings if desired
if (!html) {
x = x.replace(htmre, '');
y = y.replace(htmre, '');
}
// chunk/tokenize
var xN = x.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
yN = y.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
// numeric, hex or date detection
xD = parseInt(x.match(hre), 10) || (xN.length !== 1 && x.match(dre) && Date.parse(x)),
yD = parseInt(y.match(hre), 10) || xD && y.match(dre) && Date.parse(y) || null;
// first try and sort Hex codes or Dates
if (yD) {
if ( xD < yD ) {
return -1;
}
else if ( xD > yD ) {
return 1;
}
}
// natural sorting through split numeric strings and default strings
for(var cLoc=0, numS=Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
// find floats not starting with '0', string or 0 if not defined (Clint Priest)
var oFxNcL = !(xN[cLoc] || '').match(ore) && parseFloat(xN[cLoc], 10) || xN[cLoc] || 0;
var oFyNcL = !(yN[cLoc] || '').match(ore) && parseFloat(yN[cLoc], 10) || yN[cLoc] || 0;
// handle numeric vs string comparison - number < string - (Kyle Adams)
if (isNaN(oFxNcL) !== isNaN(oFyNcL)) {
return (isNaN(oFxNcL)) ? 1 : -1;
}
// rely on string comparison if different types - i.e. '02' < 2 != '02' < '2'
else if (typeof oFxNcL !== typeof oFyNcL) {
oFxNcL += '';
oFyNcL += '';
}
if (oFxNcL < oFyNcL) {
return -1;
}
if (oFxNcL > oFyNcL) {
return 1;
}
}
return 0;
}
jQuery.extend( jQuery.fn.dataTableExt.oSort, {
"natural-asc": function ( a, b ) {
return naturalSort(a,b,true);
},
"natural-desc": function ( a, b ) {
return naturalSort(a,b,true) * -1;
},
"natural-nohtml-asc": function( a, b ) {
return naturalSort(a,b,false);
},
"natural-nohtml-desc": function( a, b ) {
return naturalSort(a,b,false) * -1;
},
"natural-ci-asc": function( a, b ) {
a = a.toString().toLowerCase();
b = b.toString().toLowerCase();
return naturalSort(a,b,true);
},
"natural-ci-desc": function( a, b ) {
a = a.toString().toLowerCase();
b = b.toString().toLowerCase();
return naturalSort(a,b,true) * -1;
}
} );
}());

View File

@@ -7,7 +7,7 @@ import (
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
)

View File

@@ -5,17 +5,17 @@
<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>
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/datatables.min.css"/>
<script type="text/javascript" src="{{ basePath }}/static/datatables.min.js"></script>
{{yield head()}}
</head>
<body>
<div class="container">
<div style="float: left">
<h2>Docker Registry UI</h2>
<h2><a href="{{ basePath }}/" style="text-decoration: none">Docker Registry UI</a></h2>
</div>
<div style="float: right">
<a href="{{ basePath }}/events">Event Log</a>
<h4><a href="{{ basePath }}/events">Event Log</a></h4>
</div>
<div style="clear: both"></div>
@@ -23,7 +23,7 @@
<div style="padding: 10px 0; margin-bottom: 20px">
<div style="text-align: center; color:darkgrey">
Docker Registry UI v{{version}} &copy; 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
Docker Registry UI v{{version}} &copy; 2017-2022 <a href="https://quiq.com">Quiq Inc.</a>
</div>
</div>
</div>

View File

@@ -17,7 +17,6 @@
{{block body()}}
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">Home</a></li>
<li class="active">Event Log</li>
</ol>

View File

@@ -41,7 +41,10 @@
</div>
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">Home</a></li>
<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">
@@ -53,10 +56,12 @@
</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>

View File

@@ -4,38 +4,79 @@
{{block body()}}
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">Home</a></li>
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
{{end}}
<li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li>
<li class="active">{{ tag }}</li>
</ol>
<h4>Image Details</h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Image Details</th>
<th colspan="2">Summary</th>
</tr>
</thead>
<tr>
<td width="20%">Image</td><td>{{ registryHost }}/{{ repoPath }}:{{ tag }}</td>
<td width="20%"><b>Image URL</b></td><td>{{ registryHost }}/{{ repoPath }}{{ isDigest ? "@" : ":" }}{{ tag }}</td>
</tr>
<tr>
<td>sha256</td><td>{{ sha256 }}</td>
<td><b>Digest</b></td><td>sha256:{{ sha256 }}</td>
</tr>
{{if created}}
<tr>
<td><b>Created On</b></td><td>{{ created|pretty_time }}</td>
</tr>
{{end}}
{{if not digestList}}
<tr>
<td><b>Image Size</b></td><td>{{ imageSize|pretty_size }}</td>
</tr>
<tr>
<td>Created On</td><td>{{ created|pretty_time }}</td>
<td><b>Layer Count</b></td><td>{{ layersCount }}</td>
</tr>
{{end}}
<tr>
<td>Image Size</td><td>{{ imageSize|pretty_size }}</td>
</tr>
<tr>
<td>Layer Count</td><td>{{ layersCount }}</td>
<td><b>Manifest Formats</b></td>
<td>{{if not isDigest}}Manifest v2 schema 1{{else}}<font color="#c2c2c2">Manifest v2 schema 1</font>{{end}} |
{{if not digestList && layersV2}}Manifest v2 schema 2{{else}}<font color="#c2c2c2">Manifest v2 schema 2</font>{{end}} |
{{if digestList}}Manifest List v2 schema 2{{else}}<font color="#c2c2c2">Manifest List v2 schema 2</font>{{end}}
</td>
</tr>
</table>
{{if layersV2}}
<h4>Manifest v2</h4>
{{if digestList}}
<h4>Sub-images <!-- Manifest List v2 schema 2: multi-arch or cache image --></h4>
{{range index, manifest := digestList}}
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Manifest #{{ index+1 }}</th>
</tr>
</thead>
{{range key := manifest["ordered_keys"]}}
<tr>
<td width="20%">{{ key }}</td>
{{if key == "platform" || key == "annotations"}}
<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. -->
{{ manifest[key]|parse_map|raw }}
</table>
</td>
{{else if key == "size"}}
<td>{{ manifest[key]|pretty_size }}</td>
{{else}}
<td>{{ manifest[key]|raw }}</td>
{{end}}
</tr>
{{end}}
</table>
{{end}}
{{else if layersV2}}
<h4>Blobs <!-- Manifest v2 schema 2--></h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
@@ -54,7 +95,8 @@
</table>
{{end}}
<h4>Manifest v1</h4>
{{if not isDigest}}
<h4>Image History <!-- Manifest v2 schema 1--></h4>
{{range index, layer := layersV1}}
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
@@ -81,5 +123,6 @@
{{end}}
</table>
{{end}}
{{end}}
{{end}}

View File

@@ -2,7 +2,7 @@
{{block head()}}
<script type="text/javascript" src="{{ basePath }}/static/bootstrap-confirmation.min.js"></script>
<script type="text/javascript" src="https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js"></script>
<script type="text/javascript" src="{{ basePath }}/static/sorting_natural.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
@@ -32,7 +32,7 @@
{{block body()}}
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">Home</a></li>
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
{{end}}

View File

@@ -1,3 +1,3 @@
package main
const version = "0.7.3"
const version = "0.9.4"