Compare commits

..

19 Commits

Author SHA1 Message Date
Roman Vynar
e712ae59d2 Release 0.10.4: update all deps 2025-06-11 19:32:45 +03:00
Enrique Hernández Bello
7e1cff804f
Include a default config file into the container to simplify setup (#83)
* Include the default config file into the Docker image

* Add a quick start section in the README file
2024-12-08 19:48:06 +02:00
Roman Vynar
c4a70ba1df Add more error logging 2024-09-18 11:42:05 +03:00
Roman Vynar
b8faa4b9b1 Update readme 2024-08-19 10:16:13 +03:00
Roman Vynar
628d28398f Release 0.10.3 2024-08-15 14:58:14 +03:00
Roman Vynar
674562d8d7 Upgrade go, alpine, deps 2024-08-15 14:58:00 +03:00
Roman Vynar
920e4132f0 Store IPv6 addresses correctly 2024-08-15 14:56:51 +03:00
KanagawaNezumi
6dc1408576
Add 'insecure' option to enforce use of HTTP protocol for registry requests (#78) 2024-08-15 13:17:21 +03:00
Roman Vynar
1af4694889 Fix concurrent map iteration and map write 2024-06-11 14:23:39 +03:00
Roman Vynar
bbefd03dbd Release 0.10.2 2024-05-31 18:35:42 +03:00
Roman Vynar
f7e40bece8 Allow to override any config option via environment variables using SECTION_KEY_NAME syntax 2024-05-21 17:01:44 +03:00
Roman Vynar
b49076db7c Fix repo tag count when a repo name is a prefix for another repo name(s) 2024-05-06 16:47:37 +03:00
Roman Vynar
c7c3a815fb Bump github.com/docker/docker to 26.0.2 to fix 1 Dependabot alert 2024-04-19 18:10:16 +03:00
Roman Vynar
929daf733f Release 0.10.1 2024-04-19 18:07:40 +03:00
Roman Vynar
86ee1d56bd Rename -purge-from-repos to -purge-include-repos, add -purge-exclude-repos 2024-04-19 18:07:22 +03:00
Roman Vynar
d29c24a78f Make image column clickable in Event Log 2024-04-19 18:06:28 +03:00
Roman Vynar
e334d4c6c7 Major rewrite with just breaking changes 2024-04-16 13:54:18 +03:00
Roman Vynar
f91c3b9aca Add -purge-from-repos="repo1,repo2,..." and -disable-count-tags options 2024-02-22 18:15:18 +02:00
Roman Vynar
8a48bd4e8b Fix timezone support for mysql 2024-02-21 13:37:11 +02:00
44 changed files with 1321 additions and 1173 deletions

View File

@ -1,5 +1,53 @@
## Changelog ## Changelog
## 0.10.4 (2025-06-11)
* Include the default config file into the Docker image.
* Upgrade go version to 1.24.4 and all dependencies, alpine to 3.21.
## 0.10.3 (2024-08-15)
* Add `registry.insecure` option to the config (alternatively REGISTRY_INSECURE env var) to support non-https registries.
Thanks to @KanagawaNezumi
* Fix concurrent map iteration and write in rare cases.
* Upgrade go version to 1.22.6 and all dependencies, alpine to 3.20.
* IPv6 addresses were not displayed correctly.
In case you need to store registry events with IPv6 addresses in MySQL, you need to run `ALTER TABLE events MODIFY column ip varchar(45) NULL`.
For sqlite, you can start a new db file or migrate events manually as it doesn't support ALTER.
## 0.10.2 (2024-05-31)
* Fix repo tag count when a repo name is a prefix for another repo name(s)
* Allow to override any config option via environment variables using SECTION_KEY_NAME syntax, e.g.
LISTEN_ADDR, PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL, REGISTRY_HOSTNAME etc.
## 0.10.1 (2024-04-19)
* Rename cmd flag `-purge-from-repos` to `-purge-include-repos` to purge tags only for the specified repositories.
* Add a new cmd flag `-purge-exclude-repos` to skip the specified repositories from the tag purging.
* Make image column clickable in Event Log.
### 0.10.0 (2024-04-16)
**JUST BREAKING CHANGES**
* We have made a full rewrite. Over 6 years many things have been changed.
* Renamed github/dockerhub repo from docker-registry-ui -> registry-ui
* Switched from doing raw http calls to `github.com/google/go-containerregistry`
* URLs and links are now matching the image references, no more "library" or other weird URL parts.
* No namespace or only 2-level deep concept
* An arbitrary repository levels are supported
* It is even possible to list both sub-repos and tags within the same repo path if you have those
* Added support for OCI images, so now both Docker + OCI are supported
* Proper support of Image Index (Index Manifest)
* Display full information available about Image or Image Index
* Sub-images (multi-platform ones) are linked under Image Index
* Changed format of config.yml but the same concept is preserved
* Event listener path has been changed from /api/events to /event-receiver and you may need to update your registry config
* Removed built-in cron scheduler for purging tags, please use the normal cron :)
* Now you can tune the refresh of catalog and separately refresh of tag counting, disable them etc.
* Everything has been made better! :)
### 0.9.7 (2024-02-21) ### 0.9.7 (2024-02-21)
* Fix timezone support: now when running a container with `TZ` env var, e.g. "-e TZ=America/Los_Angeles", it will be reflected everywhere on UI. * Fix timezone support: now when running a container with `TZ` env var, e.g. "-e TZ=America/Los_Angeles", it will be reflected everywhere on UI.

View File

@ -1,4 +1,4 @@
FROM golang:1.22.0-alpine3.19 as builder FROM golang:1.24.4-alpine3.21 as builder
RUN apk update && \ RUN apk update && \
apk add ca-certificates git bash gcc musl-dev apk add ca-certificates git bash gcc musl-dev
@ -9,10 +9,10 @@ ADD registry registry
ADD *.go go.mod go.sum ./ ADD *.go go.mod go.sum ./
RUN go test -v ./registry && \ RUN go test -v ./registry && \
go build -o /opt/docker-registry-ui *.go go build -o /opt/registry-ui *.go
FROM alpine:3.19 FROM alpine:3.21
WORKDIR /opt WORKDIR /opt
RUN apk add --no-cache ca-certificates tzdata && \ RUN apk add --no-cache ca-certificates tzdata && \
@ -21,7 +21,8 @@ RUN apk add --no-cache ca-certificates tzdata && \
ADD templates /opt/templates ADD templates /opt/templates
ADD static /opt/static ADD static /opt/static
COPY --from=builder /opt/docker-registry-ui /opt/ ADD config.yml /opt
COPY --from=builder /opt/registry-ui /opt/
USER nobody USER nobody
ENTRYPOINT ["/opt/docker-registry-ui"] ENTRYPOINT ["/opt/registry-ui"]

View File

@ -1,10 +1,20 @@
IMAGE=quiq/docker-registry-ui IMAGE=quiq/registry-ui
VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go` VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go`
NOCACHE=--no-cache
.DEFAULT: buildx .DEFAULT_GOAL := dummy
buildx: dummy:
@docker build -t ${IMAGE}:${VERSION} . @echo "Nothing to do here."
publish: build:
@docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push . docker build ${NOCACHE} -t ${IMAGE}:${VERSION} .
public:
docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push .
debug:
docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:debug --push .
test:
docker buildx build ${NOCACHE} --platform linux/arm64 -t docker.quiq.im/registry-ui:test -t docker.quiq.sh/registry-ui:test --push .

101
README.md
View File

@ -1,40 +1,61 @@
## Docker Registry UI ## Registry UI
[![Go Report Card](https://goreportcard.com/badge/github.com/quiq/docker-registry-ui)](https://goreportcard.com/report/github.com/quiq/docker-registry-ui) [![Go Report Card](https://goreportcard.com/badge/github.com/quiq/registry-ui)](https://goreportcard.com/report/github.com/quiq/registry-ui)
### Overview ### Overview
* Web UI for Docker Registry * Web UI for Docker Registry or similar alternatives
* Browse namespaces, repositories and tags * Fast, simple and small package
* Display image details by layers * Browse catalog of repositories and tags
* Display sub-images of multi-arch or cache type of image * Show an arbitrary level of repository tree
* Support Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 and their confusing combinations * Support Docker and OCI image formats
* Fast and small, written on Go * Support image and image index manifests (multi-platform images)
* Automatically discover an authentication method (basic auth, token service etc.) * Display full information about image index and links to the underlying sub-images
* Caching the list of repositories, tag counts and refreshing in background * Display full information about image, its layers and config file (command history)
* Event listener of notification events coming from Registry * Event listener for notification events coming from Registry
* Store events in sqlite or MySQL database * Store events in Sqlite or MySQL database
* CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags * CLI option to maintain the tag retention: purge tags older than X days keeping at least Y tags etc.
* Automatically discover an authentication method: basic auth, token service, keychain etc.
* The list of repositories and tag counts are cached and refreshed in background
No TLS or authentication implemented on the UI web server itself. No TLS or authentication is implemented on the UI instance itself.
Assuming you will proxy it behind nginx, oauth2_proxy or something. Assuming you will put it behind nginx, oauth2_proxy or similar.
Docker images [quiq/docker-registry-ui](https://hub.docker.com/r/quiq/docker-registry-ui/tags/) Docker images [quiq/registry-ui](https://hub.docker.com/r/quiq/registry-ui/tags/)
### Quick start
Run a Docker registry in your host (if you don't already had one):
docker run -d --network host \
--name registry registry:2
Run registry UI directly connected to it:
docker run -d --network host \
-e REGISTRY_HOSTNAME=127.0.0.1:5000 \
-e REGISTRY_INSECURE=true \
--name registry-ui quiq/registry-ui
Push any Docker image to 127.0.0.1:5000/owner/name and go into http://127.0.0.1:8000 with
your web browser.
### Configuration ### Configuration
The configuration is stored in `config.yml` and the options are self-descriptive. The configuration is stored in `config.yml` and the options are self-descriptive.
### Run UI You can override any config option via environment variables using SECTION_KEY_NAME syntax,
e.g. `LISTEN_ADDR`, `PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL`, `REGISTRY_HOSTNAME` etc.
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro \ Passing the full config file through:
--name=registry-ui quiq/docker-registry-ui
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro quiq/registry-ui
To run with your own root CA certificate, add to the command: To run with your own root CA certificate, add to the command:
-v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro -v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro
To preserve sqlite db file with event notifications data, add to the command: To preserve sqlite db file with event data, add to the command:
-v /local/data:/opt/data -v /local/data:/opt/data
@ -53,8 +74,8 @@ To receive events you need to configure Registry as follow:
notifications: notifications:
endpoints: endpoints:
- name: docker-registry-ui - name: registry-ui
url: http://docker-registry-ui.local:8000/api/events url: http://registry-ui.local:8000/event-receiver
headers: headers:
Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890] Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890]
timeout: 1s timeout: 1s
@ -64,7 +85,7 @@ To receive events you need to configure Registry as follow:
- application/octet-stream - application/octet-stream
Adjust url and token as appropriate. Adjust url and token as appropriate.
If you are running UI from non-root base path, e.g. /ui, the URL path for above will be `/ui/api/events`. If you are running UI with non-default base path, e.g. /ui, the URL path for above will be `/ui/event-receiver` etc.
## Using MySQL instead of sqlite3 for event listener ## Using MySQL instead of sqlite3 for event listener
@ -78,7 +99,7 @@ You can create a table manually if you don't want to grant `CREATE` permission:
action CHAR(4) NULL, action CHAR(4) NULL,
repository VARCHAR(100) NULL, repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL, tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL, ip VARCHAR(45) NULL,
user VARCHAR(50) NULL, user VARCHAR(50) NULL,
created DATETIME NULL created DATETIME NULL
); );
@ -94,46 +115,26 @@ To delete tags you need to enable the corresponding option in Docker Registry co
The following example shows how to run a cron task to purge tags older than X days but also keep The following example shows how to run a cron task to purge tags older than X days but also keep
at least Y tags no matter how old. Assuming container has been already running. at least Y tags no matter how old. Assuming container has been already running.
10 3 * * * root docker exec -t registry-ui /opt/docker-registry-ui -purge-tags 10 3 * * * root docker exec -t registry-ui /opt/registry-ui -purge-tags
You can try to run in dry-run mode first to see what is going to be purged: You can try to run in dry-run mode first to see what is going to be purged:
docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run docker exec -t registry-ui /opt/registry-ui -purge-tags -dry-run
Alternatively, you can schedule the purging task with built-in cron feature:
purge_tags_schedule: '0 10 3 * * *'
Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
### Debug mode
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 ### Screenshots
Repository list / home page: Repository list:
![image](screenshots/1.png) ![image](screenshots/1.png)
Repository tag list: Tag list:
![image](screenshots/2.png) ![image](screenshots/2.png)
Tag info page: Image Index info:
![image](screenshots/3.png) ![image](screenshots/3.png)
Event log page: Image info:
![image](screenshots/4.png) ![image](screenshots/4.png)

View File

@ -1,83 +0,0 @@
package main
import (
"net/url"
"os"
"strings"
"github.com/quiq/docker-registry-ui/registry"
"gopkg.in/yaml.v2"
)
type configData struct {
ListenAddr string `yaml:"listen_addr"`
BasePath string `yaml:"base_path"`
RegistryURL string `yaml:"registry_url"`
VerifyTLS bool `yaml:"verify_tls"`
Username string `yaml:"registry_username"`
Password string `yaml:"registry_password"`
PasswordFile string `yaml:"registry_password_file"`
EventListenerToken string `yaml:"event_listener_token"`
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"`
AnyoneCanViewEvents bool `yaml:"anyone_can_view_events"`
Admins []string `yaml:"admins"`
Debug bool `yaml:"debug"`
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
PurgeTagsKeepRegexp string `yaml:"purge_tags_keep_regexp"`
PurgeTagsKeepFromFile string `yaml:"purge_tags_keep_from_file"`
PurgeTagsSchedule string `yaml:"purge_tags_schedule"`
PurgeConfig *registry.PurgeTagsConfig
}
func readConfig(configFile string) *configData {
var config configData
// Read config file.
if _, err := os.Stat(configFile); os.IsNotExist(err) {
panic(err)
}
data, err := os.ReadFile(configFile)
if err != nil {
panic(err)
}
if err := yaml.Unmarshal(data, &config); err != nil {
panic(err)
}
// Validate registry URL.
if _, err := url.Parse(config.RegistryURL); err != nil {
panic(err)
}
// Normalize base path.
config.BasePath = strings.Trim(config.BasePath, "/")
if config.BasePath != "" {
config.BasePath = "/" + config.BasePath
}
// Read password from file.
if config.PasswordFile != "" {
if _, err := os.Stat(config.PasswordFile); os.IsNotExist(err) {
panic(err)
}
data, err := os.ReadFile(config.PasswordFile)
if err != nil {
panic(err)
}
config.Password = strings.TrimSuffix(string(data[:]), "\n")
}
config.PurgeConfig = &registry.PurgeTagsConfig{
KeepDays: config.PurgeTagsKeepDays,
KeepMinCount: config.PurgeTagsKeepCount,
KeepTagRegexp: config.PurgeTagsKeepRegexp,
KeepFromFile: config.PurgeTagsKeepFromFile,
}
return &config
}

View File

@ -1,69 +1,86 @@
# Listen interface. # Listen interface.
listen_addr: 0.0.0.0:8000 listen_addr: 0.0.0.0:8000
# Base path of Docker Registry UI.
base_path: /
# Registry URL with schema and port. # Base path of Registry UI.
registry_url: https://docker-registry.local uri_base_path: /
# Verify TLS certificate when using https.
verify_tls: true
# Docker registry credentials. # Background tasks.
# They need to have a full access to the registry. performance:
# If token authentication service is enabled, it will be auto-discovered and those credentials # Catalog list page size. It depends from the underlying storage performance.
# will be used to obtain access tokens. catalog_page_size: 100
# 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
# registry_password_file: /run/secrets/registry_password_file
# Event listener token. # Catalog (repo list) refresh interval in minutes.
# The same one should be configured on Docker registry as Authorization Bearer token. # If set to 0 it will never refresh but will run once.
event_listener_token: token catalog_refresh_interval: 10
# Retention of records to keep.
event_retention_days: 7
# Event listener storage. # Tags counting refresh interval in minutes.
event_database_driver: sqlite3 # If set to 0 it will never run. This is fast operation.
event_database_location: data/registry_events.db tags_count_refresh_interval: 60
# 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 # Registry endpoint and authentication.
# cluster setup to avoid deadlocks or replication break. registry:
event_deletion_enabled: true # Registry hostname (without protocol but may include port).
hostname: docker-registry.local
# Allow to access non-https enabled registry.
insecure: false
# Cache refresh interval in minutes. # Registry credentials.
# How long to cache repository list and tag counts. # They need to have a full access to the registry.
cache_refresh_interval: 10 # If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens.
username: user
password: pass
# Set password to '' in order to read it from the file below. Otherwise, it is ignored.
password_file: /run/secrets/registry_password_file
# If all users can view the event log. If set to false, then only admins listed below. # Alternatively, you can do auth with Keychain, useful for local development.
anyone_can_view_events: true # When enabled the above credentials will not be used.
# If all users can delete tags. If set to false, then only admins listed below. auth_with_keychain: false
anyone_can_delete: false
# Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy.
admins: []
# Debug mode. Affects only templates. # UI access management.
debug: true access_control:
# Whether users can the event log. Otherwise, only admins listed below.
anyone_can_view_events: true
# Whether users can delete tags. Otherwise, only admins listed below.
anyone_can_delete_tags: false
# The list of users to do everything.
# User identifier should be set via X-WEBAUTH-USER header from your proxy
# because registry UI itself does not employ any auth.
admins: []
# How many days to keep tags but also keep the minimal count provided no matter how old. # Event listener configuration.
purge_tags_keep_days: 90 event_listener:
purge_tags_keep_count: 2 # The same token should be configured on Docker registry as Authorization Bearer token.
bearer_token: xxx
# Retention of records to keep.
retention_days: 7
# Keep tags matching regexp no matter how old, e.g. '^latest$' # Event listener storage.
# Empty string disables this feature. database_driver: sqlite3
purge_tags_keep_regexp: '' database_location: data/registry_events.db
# database_driver: mysql
# database_location: user:password@tcp(localhost:3306)/docker_events
# Keep tags listed in the file no matter how old. # You can disable event deletion on some hosts when you are running registry UI on MySQL master-master or
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]} # cluster setup to avoid deadlocks or replication breaks.
# Empty string disables this feature. deletion_enabled: true
purge_tags_keep_from_file: ''
# Enable built-in cron to schedule purging tags in server mode. # Options for tag purging.
# Empty string disables this feature. purge_tags:
# Example: '25 54 17 * * *' will run it at 17:54:25 daily. # How many days to keep tags but also keep the minimal count provided no matter how old.
# Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron keep_days: 90
purge_tags_schedule: '' keep_count: 10
# Keep tags matching regexp no matter how old, e.g. '^latest$'
# Empty string disables this feature.
keep_regexp: ''
# Keep tags listed in the file no matter how old.
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
# Empty string disables this feature.
keep_from_file: ''
# Debug mode.
debug:
# Affects only templates.
templates: false

View File

@ -4,12 +4,14 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"strings" "strings"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/registry-ui/registry"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/spf13/viper"
// 🐒 patching of "database/sql". // 🐒 patching of "database/sql".
_ "github.com/go-sql-driver/mysql" _ "github.com/go-sql-driver/mysql"
@ -18,13 +20,14 @@ import (
) )
const ( const (
userAgent = "registry-ui"
schemaSQLite = ` schemaSQLite = `
CREATE TABLE events ( CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
action CHAR(5) NULL, action CHAR(5) NULL,
repository VARCHAR(100) NULL, repository VARCHAR(100) NULL,
tag VARCHAR(100) NULL, tag VARCHAR(100) NULL,
ip VARCHAR(15) NULL, ip VARCHAR(45) NULL,
user VARCHAR(50) NULL, user VARCHAR(50) NULL,
created DATETIME NULL created DATETIME NULL
); );
@ -56,7 +59,16 @@ type EventRow struct {
} }
// NewEventListener initialize EventListener. // NewEventListener initialize EventListener.
func NewEventListener(databaseDriver, databaseLocation string, retention int, eventDeletion bool) *EventListener { func NewEventListener() *EventListener {
databaseDriver := viper.GetString("event_listener.database_driver")
databaseLocation := viper.GetString("event_listener.database_location")
retention := viper.GetInt("event_listener.retention_days")
eventDeletion := viper.GetBool("event_listener.deletion_enabled")
if databaseDriver != "sqlite3" && databaseDriver != "mysql" {
panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql"))
}
return &EventListener{ return &EventListener{
databaseDriver: databaseDriver, databaseDriver: databaseDriver,
databaseLocation: databaseLocation, databaseLocation: databaseLocation,
@ -90,8 +102,8 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
} }
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")") stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
for _, i := range gjson.GetBytes(j, "events").Array() { for _, i := range gjson.GetBytes(j, "events").Array() {
// Ignore calls by docker-registry-ui itself. // Ignore calls by registry-ui itself.
if i.Get("request.useragent").String() == "docker-registry-ui" { if strings.HasPrefix(i.Get("request.useragent").String(), userAgent) {
continue continue
} }
action := i.Get("action").String() action := i.Get("action").String()
@ -101,7 +113,10 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
if tag == "" { if tag == "" {
tag = i.Get("target.digest").String() tag = i.Get("target.digest").String()
} }
ip := strings.Split(i.Get("request.addr").String(), ":")[0] ip := i.Get("request.addr").String()
if x, _, _ := net.SplitHostPort(ip); x != "" {
ip = x
}
user := i.Get("actor.name").String() user := i.Get("actor.name").String()
e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user) e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
@ -143,7 +158,8 @@ func (e *EventListener) GetEvents(repository string) []EventRow {
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000" query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
if repository != "" { if repository != "" {
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository) query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' OR repository LIKE '%s/%%' ORDER BY id DESC LIMIT 5",
repository, repository)
} }
rows, err := db.Query(query) rows, err := db.Query(query)
if err != nil { if err != nil {

66
go.mod
View File

@ -1,39 +1,61 @@
module github.com/quiq/docker-registry-ui module github.com/quiq/registry-ui
go 1.24.0
toolchain go1.24.4
require ( require (
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible github.com/CloudyKit/jet/v6 v6.3.1
github.com/go-sql-driver/mysql v1.7.1 github.com/fatih/color v1.18.0
github.com/labstack/echo/v4 v4.11.4 github.com/go-sql-driver/mysql v1.9.2
github.com/mattn/go-sqlite3 v1.14.22 github.com/google/go-containerregistry v0.20.5
github.com/parnurzeal/gorequest v0.2.16 github.com/labstack/echo/v4 v4.13.4
github.com/robfig/cron v1.2.0 github.com/mattn/go-sqlite3 v1.14.28
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/goconvey v1.8.1 github.com/smartystreets/goconvey v1.8.1
github.com/tidwall/gjson v1.17.1 github.com/spf13/viper v1.20.1
gopkg.in/yaml.v2 v2.4.0 github.com/tidwall/gjson v1.18.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 // indirect github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect github.com/docker/cli v28.2.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.3 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/smarty/assertions v1.15.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/smarty/assertions v1.16.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.17.0 // indirect github.com/vbatts/tar-split v0.12.1 // indirect
golang.org/x/net v0.19.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sys v0.15.0 // indirect golang.org/x/crypto v0.39.0 // indirect
golang.org/x/text v0.14.0 // indirect golang.org/x/net v0.41.0 // indirect
golang.org/x/time v0.5.0 // indirect golang.org/x/sync v0.15.0 // indirect
moul.io/http2curl v1.0.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.26.0 // indirect
golang.org/x/time v0.12.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
go 1.19

134
go.sum
View File

@ -1,78 +1,126 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c= 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/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/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w= github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M= github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 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/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 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/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
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/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=

101
main.go
View File

@ -3,33 +3,37 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"net/url" "path/filepath"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "github.com/labstack/echo/v4/middleware"
"github.com/quiq/docker-registry-ui/events" "github.com/quiq/registry-ui/events"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/registry-ui/registry"
"github.com/robfig/cron"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/spf13/viper"
) )
type apiClient struct { type apiClient struct {
client *registry.Client client *registry.Client
eventListener *events.EventListener eventListener *events.EventListener
config *configData
} }
func main() { func main() {
var ( var (
a apiClient a apiClient
configFile, loggingLevel string configFile, loggingLevel string
purgeTags, purgeDryRun bool purgeTags, purgeDryRun bool
purgeIncludeRepos, purgeExcludeRepos string
) )
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file") flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
flag.StringVar(&loggingLevel, "log-level", "info", "logging level") 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(&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.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
flag.StringVar(&purgeIncludeRepos, "purge-include-repos", "", "comma-separated list of repos to purge tags from, otherwise all")
flag.StringVar(&purgeExcludeRepos, "purge-exclude-repos", "", "comma-separated list of repos to skip from purging tags, otherwise none")
flag.Parse() flag.Parse()
// Setup logging // Setup logging
@ -40,70 +44,63 @@ func main() {
} }
// Read config file // Read config file
a.config = readConfig(configFile) viper.SetConfigName(strings.Split(filepath.Base(configFile), ".")[0])
a.config.PurgeConfig.DryRun = purgeDryRun viper.AddConfigPath(filepath.Dir(configFile))
viper.AddConfigPath(".")
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("fatal error reading config file: %w", err))
}
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
// Init registry API client. // Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password) a.client = registry.NewClient()
if a.client == nil {
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
}
purgeFunc := func() {
registry.PurgeOldTags(a.client, a.config.PurgeConfig)
}
// Execute CLI task and exit. // Execute CLI task and exit.
if purgeTags { if purgeTags {
purgeFunc() registry.PurgeOldTags(a.client, purgeDryRun, purgeIncludeRepos, purgeExcludeRepos)
return return
} }
// Schedules to purge tags. go a.client.StartBackgroundJobs()
if a.config.PurgeTagsSchedule != "" { a.eventListener = events.NewEventListener()
c := cron.New()
if err := c.AddFunc(a.config.PurgeTagsSchedule, purgeFunc); err != nil {
panic(fmt.Errorf("invalid schedule format: %s", a.config.PurgeTagsSchedule))
}
c.Start()
}
// Count tags in background.
go a.client.CountTags(a.config.CacheRefreshInterval)
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.config.EventDeletionEnabled,
)
// Template engine init. // Template engine init.
e := echo.New() e := echo.New()
registryHost, _ := url.Parse(a.config.RegistryURL) // validated already in config.go // e.Use(middleware.Logger())
e.Renderer = setupRenderer(a.config.Debug, registryHost.Host, a.config.BasePath) e.Use(loggingMiddleware())
e.Use(recoverMiddleware())
basePath := viper.GetString("uri_base_path")
// Normalize base path.
basePath = strings.Trim(basePath, "/")
if basePath != "" {
basePath = "/" + basePath
}
e.Renderer = setupRenderer(basePath)
// Web routes. // Web routes.
e.File("/favicon.ico", "static/favicon.ico") e.File("/favicon.ico", "static/favicon.ico")
e.Static(a.config.BasePath+"/static", "static") e.Static(basePath+"/static", "static")
if a.config.BasePath != "" {
e.GET(a.config.BasePath, a.viewRepositories) p := e.Group(basePath)
if basePath != "" {
e.GET(basePath, a.viewCatalog)
} }
e.GET(a.config.BasePath+"/", a.viewRepositories) p.GET("/", a.viewCatalog)
e.GET(a.config.BasePath+"/:namespace", a.viewRepositories) p.GET("/:repoPath", a.viewCatalog)
e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags) p.GET("/event-log", a.viewEventLog)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo) p.GET("/delete-tag", a.deleteTag)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
e.GET(a.config.BasePath+"/events", a.viewLog)
// Protected event listener. // Protected event listener.
p := e.Group(a.config.BasePath + "/api") pp := e.Group("/event-receiver")
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{ pp.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) { Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
return token == a.config.EventListenerToken, nil return token == viper.GetString("event_listener.bearer_token"), nil
}), }),
})) }))
p.POST("/events", a.receiveEvents) pp.POST("", a.receiveEvents)
e.Logger.Fatal(e.Start(a.config.ListenAddr)) e.Logger.Fatal(e.Start(viper.GetString("listen_addr")))
} }

84
middleware.go Normal file
View File

@ -0,0 +1,84 @@
package main
import (
"bytes"
"fmt"
"io"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/labstack/echo/v4"
"github.com/quiq/registry-ui/registry"
"github.com/sirupsen/logrus"
)
// loggingMiddleware logging of the web framework
func loggingMiddleware() echo.MiddlewareFunc {
logger := registry.SetupLogging("echo")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) (err error) {
req := ctx.Request()
// Skip logging for specific paths.
if strings.HasSuffix(req.RequestURI, "/event-receiver") {
return next(ctx)
}
// Log the original request in DEBUG mode.
if logrus.GetLevel() == logrus.DebugLevel && req.Body != nil {
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
if len(bodyBytes) > 0 {
logger.Debugf("Incoming HTTP %s request: %s", req.Method, string(bodyBytes))
}
}
res := ctx.Response()
start := time.Now()
if err = next(ctx); err != nil {
ctx.Error(err)
}
stop := time.Now()
statusCode := color.GreenString("%d", res.Status)
switch {
case res.Status >= 500:
statusCode = color.RedString("%d", res.Status)
case res.Status >= 400:
statusCode = color.YellowString("%d", res.Status)
case res.Status >= 300:
statusCode = color.CyanString("%d", res.Status)
}
latency := stop.Sub(start).Round(1 * time.Millisecond).String() // human readable
// latency := strconv.FormatInt(int64(stop.Sub(start)), 10) // in ns
// Do main logging.
logger.Infof("%s %s %s %s %s %s", ctx.RealIP(), req.Method, req.RequestURI, statusCode, latency, req.UserAgent())
return
}
}
}
// recoverMiddleware recover from panics
func recoverMiddleware() echo.MiddlewareFunc {
logger := registry.SetupLogging("echo")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(ctx echo.Context) error {
defer func() {
if r := recover(); r != nil {
err, ok := r.(error)
if !ok {
err = fmt.Errorf("%v", r)
}
stackSize := 4 << 10 // 4 KB
stack := make([]byte, stackSize)
length := runtime.Stack(stack, true)
logger.Errorf("[PANIC RECOVER] %v %s\n", err, stack[:length])
}
}()
return next(ctx)
}
}
}

View File

@ -1,324 +1,363 @@
package registry package registry
import ( import (
"crypto" "context"
"crypto/tls" "encoding/json"
"fmt" "os"
"regexp"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/parnurzeal/gorequest" "github.com/google/go-containerregistry/pkg/authn"
"github.com/google/go-containerregistry/pkg/name"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/sirupsen/logrus" "github.com/sirupsen/logrus"
"github.com/tidwall/gjson" "github.com/spf13/viper"
) )
const userAgent = "docker-registry-ui" const userAgent = "registry-ui"
// Client main class. // Client main class.
type Client struct { type Client struct {
url string puller *remote.Puller
verifyTLS bool pusher *remote.Pusher
username string logger *logrus.Entry
password string repos []string
request *gorequest.SuperAgent tagCountsMux sync.Mutex
logger *logrus.Entry tagCounts map[string]int
mux sync.Mutex isCatalogReady bool
tokens map[string]string nameOptions []name.Option
repos map[string][]string }
tagCounts map[string]int
authURL string type ImageInfo struct {
IsImageIndex bool
IsImage bool
ImageRefRepo string
ImageRefTag string
ImageRefDigest string
MediaType string
Platforms string
Manifest map[string]interface{}
// Image specific
ImageSize int64
Created time.Time
ConfigImageID string
ConfigFile map[string]interface{}
} }
// NewClient initialize Client. // NewClient initialize Client.
func NewClient(url string, verifyTLS bool, username, password string) *Client { func NewClient() *Client {
c := &Client{ var authOpt remote.Option
url: strings.TrimRight(url, "/"), if viper.GetBool("registry.auth_with_keychain") {
verifyTLS: verifyTLS, authOpt = remote.WithAuthFromKeychain(authn.DefaultKeychain)
username: username,
password: password,
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
logger: SetupLogging("registry.client"),
tokens: map[string]string{},
repos: map[string][]string{},
tagCounts: map[string]int{},
}
resp, _, errs := c.request.Get(c.url+"/v2/").
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return nil
}
authHeader := ""
if resp.StatusCode == 200 {
return c
} else if resp.StatusCode == 401 {
authHeader = resp.Header.Get("WWW-Authenticate")
} else { } else {
c.logger.Error(resp.Status) password := viper.GetString("registry.password")
return nil if password == "" {
passwdFile := viper.GetString("registry.password_file")
if _, err := os.Stat(passwdFile); os.IsNotExist(err) {
panic(err)
}
data, err := os.ReadFile(passwdFile)
if err != nil {
panic(err)
}
password = strings.TrimSuffix(string(data[:]), "\n")
}
authOpt = remote.WithAuth(authn.FromConfig(authn.AuthConfig{
Username: viper.GetString("registry.username"), Password: password,
}))
} }
if strings.HasPrefix(authHeader, "Bearer") { pageSize := viper.GetInt("performance.catalog_page_size")
r, _ := regexp.Compile(`^Bearer realm="(http.+)",service="(.+)"`) puller, _ := remote.NewPuller(authOpt, remote.WithUserAgent(userAgent), remote.WithPageSize(pageSize))
if m := r.FindStringSubmatch(authHeader); len(m) > 0 { pusher, _ := remote.NewPusher(authOpt, remote.WithUserAgent(userAgent))
c.authURL = fmt.Sprintf("%s?service=%s", m[1], m[2])
c.logger.Info("Token auth service discovered at ", c.authURL) insecure := viper.GetBool("registry.insecure")
} nameOptions := []name.Option{}
if c.authURL == "" { if insecure {
c.logger.Warn("No token auth service discovered from ", c.url) nameOptions = append(nameOptions, name.Insecure)
return nil
}
} else if strings.HasPrefix(strings.ToLower(authHeader), "basic") {
c.request = c.request.SetBasicAuth(c.username, c.password)
c.logger.Info("It was discovered the registry is configured with HTTP basic auth.")
} }
c := &Client{
puller: puller,
pusher: pusher,
logger: SetupLogging("registry.client"),
repos: []string{},
tagCounts: map[string]int{},
nameOptions: nameOptions,
}
return c return c
} }
// getToken get existing or new auth token. func (c *Client) StartBackgroundJobs() {
func (c *Client) getToken(scope string) string { catalogInterval := viper.GetInt("performance.catalog_refresh_interval")
// Check if we have already a token and it's not expired. tagsCountInterval := viper.GetInt("performance.tags_count_refresh_interval")
if token, ok := c.tokens[scope]; ok { isStarted := false
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", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return ""
}
if resp.StatusCode != 200 {
c.logger.Error("Failed to get token for scope ", scope, " from ", c.authURL)
return ""
}
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 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", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return "", resp
}
c.logger.Debugf("GET %s %s", uri, resp.Status)
// Returns 404 when no tags in the repo.
if resp.StatusCode != 200 {
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 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, 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 {
// Return from cache if available.
if len(c.repos) > 0 && useCache {
return c.repos
}
c.mux.Lock()
defer c.mux.Unlock()
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
scope := "registry:catalog:*"
uri := "/v2/_catalog"
tmp := map[string][]string{}
for { for {
data, resp := c.callRegistry(uri, scope, "manifest.v2") c.RefreshCatalog()
if data == "" { if !isStarted && tagsCountInterval > 0 {
c.repos = tmp // Start after the first catalog refresh
return c.repos go c.CountTags(tagsCountInterval)
isStarted = true
} }
if catalogInterval == 0 {
for _, r := range gjson.Get(data, "repositories").Array() { c.logger.Warn("Catalog refresh is disabled in the config and will not run anymore.")
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 break
} }
time.Sleep(time.Duration(catalogInterval) * time.Minute)
} }
c.repos = tmp
}
func (c *Client) RefreshCatalog() {
ctx := context.Background()
start := time.Now()
c.logger.Info("[RefreshCatalog] Started reading catalog...")
registry, _ := name.NewRegistry(viper.GetString("registry.hostname"), c.nameOptions...)
cat, err := c.puller.Catalogger(ctx, registry)
if err != nil {
c.logger.Errorf("[RefreshCatalog] Error fetching catalog: %s", err)
if !c.isCatalogReady {
os.Exit(1)
}
return
}
repos := []string{}
// The library itself does retries under the hood.
for cat.HasNext() {
data, err := cat.Next(ctx)
if err != nil {
c.logger.Errorf("[RefreshCatalog] Error listing catalog: %s", err)
}
if data != nil {
repos = append(repos, data.Repos...)
if !c.isCatalogReady {
c.repos = append(c.repos, data.Repos...)
c.logger.Debug("[RefreshCatalog] Repo batch received:", data.Repos)
}
}
}
if len(repos) > 0 {
c.repos = repos
} else {
c.logger.Warn("[RefreshCatalog] Catalog looks empty, preserving previous list if any.")
}
c.logger.Debugf("[RefreshCatalog] Catalog: %s", c.repos)
c.logger.Infof("[RefreshCatalog] Job complete (%v): %d repos found", time.Since(start), len(c.repos))
c.isCatalogReady = true
}
// IsCatalogReady whether catalog is ready for the first time use
func (c *Client) IsCatalogReady() bool {
return c.isCatalogReady
}
// GetRepos get all repos
func (c *Client) GetRepos() []string {
return c.repos return c.repos
} }
// Tags get tags for the repo. // ListTags get tags for the repo
func (c *Client) Tags(repo string) []string { func (c *Client) ListTags(repoName string) []string {
scope := fmt.Sprintf("repository:%s:*", repo) ctx := context.Background()
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, "manifest.v2") repo, _ := name.NewRepository(viper.GetString("registry.hostname")+"/"+repoName, c.nameOptions...)
var tags []string tags, err := c.puller.List(ctx, repo)
for _, t := range gjson.Get(data, "tags").Array() { if err != nil {
tags = append(tags, t.String()) c.logger.Errorf("Error listing tags for repo %s: %s", repoName, err)
} }
c.tagCountsMux.Lock()
c.tagCounts[repoName] = len(tags)
c.tagCountsMux.Unlock()
return tags return tags
} }
// ManifestList gets manifest list entries for a tag for the repo. // GetImageInfo get image info by the reference - tag name or digest sha256.
func (c *Client) ManifestList(repo, tag string) (string, []gjson.Result) { func (c *Client) GetImageInfo(imageRef string) (ImageInfo, error) {
scope := fmt.Sprintf("repository:%s:*", repo) ctx := context.Background()
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag) ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
// If manifest.list.v2 does not exist because it's a normal image, if err != nil {
// the registry returns manifest.v1 or manifest.v2 if requested by sha256. c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
info, resp := c.callRegistry(uri, scope, "manifest.list.v2") return ImageInfo{}, err
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) descr, err := c.puller.Get(ctx, ref)
return sha256, gjson.Get(info, "manifests").Array() if err != nil {
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
return ImageInfo{}, err
}
ii := ImageInfo{
ImageRefRepo: ref.Context().RepositoryStr(),
ImageRefTag: ref.Identifier(),
ImageRefDigest: descr.Digest.String(),
MediaType: string(descr.MediaType),
}
if descr.MediaType.IsIndex() {
ii.IsImageIndex = true
} else if descr.MediaType.IsImage() {
ii.IsImage = true
} else {
c.logger.Errorf("Image reference %s is neither Index nor Image", imageRef)
return ImageInfo{}, err
}
if ii.IsImage {
img, err := descr.Image()
if err != nil {
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
return ImageInfo{}, err
}
cfg, err := img.ConfigFile()
if err != nil {
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
return ImageInfo{}, err
}
ii.Created = cfg.Created.Time
ii.Platforms = getPlatform(cfg.Platform())
ii.ConfigFile = structToMap(cfg)
// ImageID is what is shown in the terminal when doing "docker images".
// This is a config sha256 of the corresponding image manifest (single platform).
if x, _ := img.ConfigName(); len(x.String()) > 19 {
ii.ConfigImageID = x.String()[7:19]
}
mf, _ := img.Manifest()
for _, l := range mf.Layers {
ii.ImageSize += l.Size
}
ii.Manifest = structToMap(mf)
} else if ii.IsImageIndex {
// In case of Image Index, if we request for Image() > ConfigFile(), it will be resolved
// to a config of one of the manifests (one of the platforms).
// It doesn't make a lot of sense, even they are usually identical. Also extra API calls which slows things down.
imgIdx, err := descr.ImageIndex()
if err != nil {
c.logger.Errorf("Cannot convert descriptor to ImageIndex for image reference %s: %s", imageRef, err)
return ImageInfo{}, err
}
IdxMf, _ := imgIdx.IndexManifest()
platforms := []string{}
for _, m := range IdxMf.Manifests {
platforms = append(platforms, getPlatform(m.Platform))
}
ii.Platforms = strings.Join(UniqueSortedSlice(platforms), ", ")
ii.Manifest = structToMap(IdxMf)
}
return ii, nil
} }
// TagInfo get image info for the repo tag or digest sha256. func getPlatform(p *v1.Platform) string {
func (c *Client) TagInfo(repo, tag string, v1only bool) (string, string, string) { if p != nil {
scope := fmt.Sprintf("repository:%s:*", repo) return p.String()
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, ""
} }
return ""
// 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
} }
// TagCounts return map with tag counts. // structToMap convert struct to map so it can be formatted as HTML table easily
func (c *Client) TagCounts() map[string]int { func structToMap(obj interface{}) map[string]interface{} {
return c.tagCounts var res map[string]interface{}
jsonBytes, _ := json.Marshal(obj)
json.Unmarshal(jsonBytes, &res)
return res
}
// GetImageCreated get image created time
func (c *Client) GetImageCreated(imageRef string) time.Time {
zeroTime := new(time.Time)
ctx := context.Background()
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
if err != nil {
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
return *zeroTime
}
descr, err := c.puller.Get(ctx, ref)
if err != nil {
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
return *zeroTime
}
// In case of ImageIndex, it is resolved to a random sub-image which should be fine.
img, err := descr.Image()
if err != nil {
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
return *zeroTime
}
cfg, err := img.ConfigFile()
if err != nil {
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
return *zeroTime
}
return cfg.Created.Time
}
// SubRepoTagCounts return map with tag counts according to the provided list of repos/sub-repos etc.
func (c *Client) SubRepoTagCounts(repoPath string, repos []string) map[string]int {
counts := map[string]int{}
for _, r := range repos {
subRepo := r
if repoPath != "" {
subRepo = repoPath + "/" + r
}
// Acquire lock to prevent concurrent map iteration and map write.
c.tagCountsMux.Lock()
for k, v := range c.tagCounts {
if k == subRepo || strings.HasPrefix(k, subRepo+"/") {
counts[subRepo] = counts[subRepo] + v
}
}
c.tagCountsMux.Unlock()
}
return counts
} }
// CountTags count repository tags in background regularly. // CountTags count repository tags in background regularly.
func (c *Client) CountTags(interval uint8) { func (c *Client) CountTags(interval int) {
for { for {
start := time.Now() start := time.Now()
c.logger.Info("[CountTags] Calculating image tags...") c.logger.Info("[CountTags] Started counting tags...")
catalog := c.Repositories(false) for _, r := range c.repos {
for n, repos := range catalog { c.ListTags(r)
for _, r := range repos {
repoPath := r
if n != "library" {
repoPath = fmt.Sprintf("%s/%s", n, r)
}
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
}
} }
c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start)) c.logger.Infof("[CountTags] Job complete (%v).", time.Since(start))
time.Sleep(time.Duration(interval) * time.Minute) time.Sleep(time.Duration(interval) * time.Minute)
} }
} }
// DeleteTag delete image tag. // DeleteTag delete image tag.
func (c *Client) DeleteTag(repo, tag string) { func (c *Client) DeleteTag(repoPath, tag string) {
scope := fmt.Sprintf("repository:%s:*", repo) ctx := context.Background()
// Get sha256 digest for tag. imageRef := repoPath + ":" + tag
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2") ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
if err != nil {
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" { c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2") return
}
// Get manifest so we have a digest to delete by
descr, err := c.puller.Get(ctx, ref)
if err != nil {
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
return
}
// Parse image reference by digest now
imageRefDigest := ref.Context().RepositoryStr() + "@" + descr.Digest.String()
ref, err = name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRefDigest, c.nameOptions...)
if err != nil {
c.logger.Errorf("Error parsing image reference %s: %s", imageRefDigest, err)
return
} }
// Delete by manifest digest reference. // Delete tag using digest.
authHeader := "" // Note, it will also delete any other tags pointing to the same digest!
if c.authURL != "" { err = c.pusher.Delete(ctx, ref)
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope)) if err != nil {
} c.logger.Errorf("Error deleting image %s: %s", imageRef, err)
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest")) return
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)
} }
c.tagCountsMux.Lock()
c.tagCounts[repoPath]--
c.tagCountsMux.Unlock()
c.logger.Infof("Image %s has been successfully deleted.", imageRef)
} }

View File

@ -58,3 +58,19 @@ func ItemInSlice(item string, slice []string) bool {
} }
return false return false
} }
// UniqueSortedSlice filter out duplicate items from slice
func UniqueSortedSlice(slice []string) []string {
sort.Strings(slice)
seen := make(map[string]struct{}, len(slice))
j := 0
for _, i := range slice {
if _, ok := seen[i]; ok {
continue
}
seen[i] = struct{}{}
slice[j] = i
j++
}
return slice[:j]
}

View File

@ -34,9 +34,9 @@ func TestSortedMapKeys(t *testing.T) {
"zoo": "bar", "zoo": "bar",
} }
b := map[string]timeSlice{ b := map[string]timeSlice{
"zoo": []tagData{{name: "1", created: time.Now()}}, "zoo": []TagData{{name: "1", created: time.Now()}},
"abc": []tagData{{name: "1", created: time.Now()}}, "abc": []TagData{{name: "1", created: time.Now()}},
"foo": []tagData{{name: "1", created: time.Now()}}, "foo": []TagData{{name: "1", created: time.Now()}},
} }
c := map[string][]string{ c := map[string][]string{
"zoo": {"1", "2"}, "zoo": {"1", "2"},

View File

@ -6,29 +6,23 @@ import (
"os" "os"
"regexp" "regexp"
"sort" "sort"
"strings"
"time" "time"
"github.com/spf13/viper"
"github.com/tidwall/gjson" "github.com/tidwall/gjson"
) )
type PurgeTagsConfig struct { type TagData struct {
DryRun bool
KeepDays int
KeepMinCount int
KeepTagRegexp string
KeepFromFile string
}
type tagData struct {
name string name string
created time.Time created time.Time
} }
func (t tagData) String() string { func (t TagData) String() string {
return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05")) return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05"))
} }
type timeSlice []tagData type timeSlice []TagData
func (p timeSlice) Len() int { func (p timeSlice) Len() int {
return len(p) return len(p)
@ -36,7 +30,7 @@ func (p timeSlice) Len() int {
func (p timeSlice) Less(i, j int) bool { func (p timeSlice) Less(i, j int) bool {
// reverse sort tags on name if equal dates (OCI image case) // reverse sort tags on name if equal dates (OCI image case)
// see https://github.com/Quiq/docker-registry-ui/pull/62 // see https://github.com/Quiq/registry-ui/pull/62
if p[i].created.Equal(p[j].created) { if p[i].created.Equal(p[j].created) {
return p[i].name > p[j].name return p[i].name > p[j].name
} }
@ -48,67 +42,83 @@ func (p timeSlice) Swap(i, j int) {
} }
// PurgeOldTags purge old tags. // PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, config *PurgeTagsConfig) { func PurgeOldTags(client *Client, purgeDryRun bool, purgeIncludeRepos, purgeExcludeRepos string) {
logger := SetupLogging("registry.tasks.PurgeOldTags") logger := SetupLogging("registry.tasks.PurgeOldTags")
keepDays := viper.GetInt("purge_tags.keep_days")
var keepTagsFromFile gjson.Result keepCount := viper.GetInt("purge_tags.keep_count")
if config.KeepFromFile != "" { keepRegexp := viper.GetString("purge_tags.keep_regexp")
if _, err := os.Stat(config.KeepFromFile); os.IsNotExist(err) { keepFromFile := viper.GetString("purge_tags.keep_from_file")
logger.Warnf("Cannot open %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
data, err := os.ReadFile(config.KeepFromFile)
if err != nil {
logger.Warnf("Cannot read %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
keepTagsFromFile = gjson.ParseBytes(data)
}
dryRunText := "" dryRunText := ""
if config.DryRun { if purgeDryRun {
logger.Warn("Dry-run mode enabled.") logger.Warn("Dry-run mode enabled.")
dryRunText = "skipped" dryRunText = "skipped"
} }
logger.Info("Scanning registry for repositories, tags and their creation dates...")
catalog := client.Repositories(true) var dataFromFile gjson.Result
// catalog := map[string][]string{"library": []string{""}} if keepFromFile != "" {
if _, err := os.Stat(keepFromFile); os.IsNotExist(err) {
logger.Warnf("Cannot open %s: %s", keepFromFile, err)
logger.Error("Not purging anything!")
return
}
data, err := os.ReadFile(keepFromFile)
if err != nil {
logger.Warnf("Cannot read %s: %s", keepFromFile, err)
logger.Error("Not purging anything!")
return
}
dataFromFile = gjson.ParseBytes(data)
}
catalog := []string{}
if purgeIncludeRepos != "" {
logger.Infof("Including repositories: %s", purgeIncludeRepos)
catalog = append(catalog, strings.Split(purgeIncludeRepos, ",")...)
} else {
client.RefreshCatalog()
catalog = client.GetRepos()
}
if purgeExcludeRepos != "" {
logger.Infof("Excluding repositories: %s", purgeExcludeRepos)
tmpCatalog := []string{}
for _, repo := range catalog {
if !ItemInSlice(repo, strings.Split(purgeExcludeRepos, ",")) {
tmpCatalog = append(tmpCatalog, repo)
}
}
catalog = tmpCatalog
}
logger.Infof("Working on repositories: %s", catalog)
now := time.Now().UTC() now := time.Now().UTC()
repos := map[string]timeSlice{} repos := map[string]timeSlice{}
count := 0 count := 0
for namespace := range catalog { for _, repo := range catalog {
count = count + len(catalog[namespace]) tags := client.ListTags(repo)
for _, repo := range catalog[namespace] { if len(tags) == 0 {
if namespace != "library" { continue
repo = fmt.Sprintf("%s/%s", namespace, repo) }
} logger.Infof("[%s] scanning %d tags...", repo, len(tags))
for _, tag := range tags {
tags := client.Tags(repo) imageRef := repo + ":" + tag
if len(tags) == 0 { created := client.GetImageCreated(imageRef)
if created.IsZero() {
// Image manifest with zero creation time, e.g. cosign w/o --record-creation-timestamp
logger.Debugf("[%s] tag with zero creation time: %s", repo, tag)
continue continue
} }
logger.Infof("[%s] scanning %d tags...", repo, len(tags)) repos[repo] = append(repos[repo], TagData{name: tag, created: created})
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})
}
} }
} }
logger.Infof("Scanned %d repositories.", count) logger.Infof("Scanned %d repositories.", len(catalog))
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", config.KeepDays, config.KeepMinCount) logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", keepDays, keepCount)
if config.KeepTagRegexp != "" { if keepRegexp != "" {
logger.Infof("Keeping tags matching regexp: %s", config.KeepTagRegexp) logger.Infof("Keeping tags matching regexp: %s", keepRegexp)
} }
if config.KeepFromFile != "" { if keepFromFile != "" {
logger.Infof("Keeping tags for repos from the file: %+v", keepTagsFromFile) logger.Infof("Keeping tags from file: %+v", dataFromFile)
} }
purgeTags := map[string][]string{} purgeTags := map[string][]string{}
keepTags := map[string][]string{} keepTags := map[string][]string{}
@ -119,19 +129,19 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
// Prep the list of tags to preserve if defined in the file // Prep the list of tags to preserve if defined in the file
tagsFromFile := []string{} tagsFromFile := []string{}
for _, i := range keepTagsFromFile.Get(repo).Array() { for _, i := range dataFromFile.Get(repo).Array() {
tagsFromFile = append(tagsFromFile, i.String()) tagsFromFile = append(tagsFromFile, i.String())
} }
// Filter out tags // Filter out tags
for _, tag := range repos[repo] { for _, tag := range repos[repo] {
daysOld := int(now.Sub(tag.created).Hours() / 24) daysOld := int(now.Sub(tag.created).Hours() / 24)
keepByRegexp := false matchByRegexp := false
if config.KeepTagRegexp != "" { if keepRegexp != "" {
keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name) matchByRegexp, _ = regexp.MatchString(keepRegexp, tag.name)
} }
if daysOld > config.KeepDays && !keepByRegexp && !ItemInSlice(tag.name, tagsFromFile) { if daysOld > keepDays && !matchByRegexp && !ItemInSlice(tag.name, tagsFromFile) {
purgeTags[repo] = append(purgeTags[repo], tag.name) purgeTags[repo] = append(purgeTags[repo], tag.name)
} else { } else {
keepTags[repo] = append(keepTags[repo], tag.name) keepTags[repo] = append(keepTags[repo], tag.name)
@ -139,9 +149,9 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
} }
// Keep minimal count of tags no matter how old they are. // Keep minimal count of tags no matter how old they are.
if len(keepTags[repo]) < config.KeepMinCount { if len(keepTags[repo]) < keepCount {
// At least "threshold"-"keep" but not more than available for "purge". // At least "threshold"-"keep" but not more than available for "purge".
takeFromPurge := int(math.Min(float64(config.KeepMinCount-len(keepTags[repo])), float64(len(purgeTags[repo])))) takeFromPurge := int(math.Min(float64(keepCount-len(keepTags[repo])), float64(len(purgeTags[repo]))))
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...) keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...)
purgeTags[repo] = purgeTags[repo][takeFromPurge:] purgeTags[repo] = purgeTags[repo][takeFromPurge:]
} }
@ -162,7 +172,7 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
continue continue
} }
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText) logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
if config.DryRun { if purgeDryRun {
continue continue
} }
for _, tag := range purgeTags[repo] { for _, tag := range purgeTags[repo] {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 265 KiB

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 305 KiB

5
static/css/bootstrap-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

22
static/css/datatables.min.css vendored Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

File diff suppressed because one or more lines are too long

View File

@ -3,13 +3,12 @@ package main
import ( import (
"fmt" "fmt"
"io" "io"
"net/url"
"time" "time"
"github.com/CloudyKit/jet" "github.com/CloudyKit/jet/v6"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/registry-ui/registry"
"github.com/tidwall/gjson" "github.com/spf13/viper"
) )
// Template Jet template. // Template Jet template.
@ -35,41 +34,45 @@ func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
} }
// setupRenderer template engine init. // setupRenderer template engine init.
func setupRenderer(debug bool, registryHost, basePath string) *Template { func setupRenderer(basePath string) *Template {
view := jet.NewHTMLSet("templates") var opts []jet.Option
view.SetDevelopmentMode(debug) if viper.GetBool("debug.templates") {
opts = append(opts, jet.InDevelopmentMode())
}
view := jet.NewSet(jet.NewOSFileSystemLoader("templates"), opts...)
view.AddGlobal("version", version) view.AddGlobal("version", version)
view.AddGlobal("basePath", basePath) view.AddGlobal("basePath", basePath)
view.AddGlobal("registryHost", registryHost) view.AddGlobal("registryHost", viper.GetString("registry.hostname"))
view.AddGlobal("pretty_size", func(size interface{}) string { view.AddGlobal("pretty_size", func(val interface{}) string {
var value float64 var s float64
switch i := size.(type) { switch i := val.(type) {
case gjson.Result:
value = float64(i.Int())
case int64: case int64:
value = float64(i) s = float64(i)
case float64:
s = i
default:
fmt.Printf("Unhandled type when calling pretty_size(): %T\n", i)
} }
return registry.PrettySize(value) return registry.PrettySize(s)
}) })
view.AddGlobal("pretty_time", func(timeVal interface{}) string { view.AddGlobal("pretty_time", func(val interface{}) string {
t, _ := time.Parse("2006-01-02T15:04:05Z", timeVal.(string)) var t time.Time
switch i := val.(type) {
case string:
var err error
t, err = time.Parse("2006-01-02T15:04:05Z", i)
if err != nil {
// mysql case
t, _ = time.Parse("2006-01-02 15:04:05", i)
}
default:
t = i.(time.Time)
}
return t.In(time.Local).Format("2006-01-02 15:04:05 MST") return t.In(time.Local).Format("2006-01-02 15:04:05 MST")
}) })
view.AddGlobal("parse_map", func(m interface{}) string { view.AddGlobal("sort_map_keys", func(m interface{}) []string {
var res string return registry.SortedMapKeys(m)
for _, k := range registry.SortedMapKeys(m) {
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
}
return res
}) })
view.AddGlobal("url_decode", func(m interface{}) string {
res, err := url.PathUnescape(m.(string))
if err != nil {
return m.(string)
}
return res
})
return &Template{View: view} return &Template{View: view}
} }

View File

@ -4,19 +4,20 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Docker Registry UI</title> <title>Registry UI</title>
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/datatables.min.css"/> <link rel="stylesheet" type="text/css" href="{{ basePath }}/static/css/bootstrap-icons.min.css">
<script type="text/javascript" src="{{ basePath }}/static/datatables.min.js"></script> <link rel="stylesheet" type="text/css" href="{{ basePath }}/static/css/datatables.min.css"/>
<script type="text/javascript" src="{{ basePath }}/static/js/datatables.min.js"></script>
{{yield head()}} {{yield head()}}
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<div style="float: left"> <div style="float: left">
<h2><a href="{{ basePath }}/" style="text-decoration: none">Docker Registry UI</a></h2> <h2><a href="{{ basePath }}/" style="text-decoration: none"><i class="bi-journals"></i> Registry UI</a></h2>
</div> </div>
{{if eventsAllowed}} {{if eventsAllowed}}
<div style="float: right"> <div style="float: right">
<h4><a href="{{ basePath }}/events">Event Log</a></h4> <h4><a href="{{ basePath }}/event-log" style="text-decoration: none"><i class="bi-calendar-week"></i> Event Log</a></h4>
</div> </div>
{{end}} {{end}}
<div style="clear: both"></div> <div style="clear: both"></div>
@ -25,7 +26,7 @@
<div style="padding: 10px 0; margin-bottom: 20px"> <div style="padding: 10px 0; margin-bottom: 20px">
<div style="text-align: center; color:darkgrey"> <div style="text-align: center; color:darkgrey">
Docker Registry UI v{{version}} | <a href="https://quiq.com">Quiq Inc.</a> Registry UI v{{version}} | <a href="https://quiq.com" target="_blank">Quiq Inc.</a> | <a href="https://github.com/Quiq/registry-ui" target="_blank">Github</a>
</div> </div>
</div> </div>
</div> </div>

10
templates/breadcrumb.html Normal file
View File

@ -0,0 +1,10 @@
{{ block breadcrumb() }}
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if . != nil}}
{{x := ""}}
{{range _, p := split(., "/")}}
{{x = x + "/" + p}}
<li><a href="{{ basePath }}{{ x }}">{{ p }}</a></li>
{{end}}
{{end}}
{{ end }}

121
templates/catalog.html Normal file
View File

@ -0,0 +1,121 @@
{{extends "base.html"}}
{{import "breadcrumb.html"}}
{{block head()}}
<script type="text/javascript" src="{{ basePath }}/static/js/bootstrap-confirmation.min.js"></script>
<script type="text/javascript" src="{{ basePath }}/static/js/sorting_natural.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('#datatable_repos').DataTable({
"pageLength": 10,
"stateSave": true,
"language": {
"emptyTable": "Catalog is being initializing..."
}
});
$('#datatable_tags').DataTable({
"pageLength": 10,
"order": [[ 0, 'desc' ]],
"stateSave": true,
columnDefs: [
{ type: 'natural', targets: 0 }
],
"language": {
"emptyTable": "No tags."
}
})
function populateConfirmation() {
$('[data-toggle=confirmation]').confirmation({
rootSelector: '[data-toggle=confirmation]',
container: 'body'
});
}
populateConfirmation()
$('#datatable_tags').on('draw.dt', populateConfirmation)
});
</script>
{{end}}
{{block body()}}
<ol class="breadcrumb">
{{ yield breadcrumb() repoPath }}
</ol>
{{if len(repos)>0 || !isCatalogReady}}
<h4>List of Repositories</h4>
<table id="datatable_repos" class="table table-striped table-bordered dataTables_wrapper">
<thead bgcolor="#ddd">
<tr>
<th>Repository</th>
<th width="20%">Tags</th>
</tr>
</thead>
<tbody>
{{range _, repo := repos}}
{{ full_repo_path := repoPath != "" ? repoPath+"/"+repo : repo }}
{{if !isset(tagCounts[full_repo_path]) || (isset(tagCounts[full_repo_path]) && tagCounts[full_repo_path] > 0)}}
<tr>
<td><i class="bi bi-folder2" style="margin-right: 10px"></i> <a href="{{ basePath }}/{{ full_repo_path }}">{{ repo }}</a></td>
<td>{{ tagCounts[full_repo_path] }}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
{{end}} {* end repos *}
{{if len(tags)>0}}
<h4>List of Tags</h4>
<table id="datatable_tags" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Tag Name</th>
</tr>
</thead>
<tbody>
{{range _, tag := tags}}
<tr>
<td>
<i class="bi bi-file-text" style="margin-right: 10px"></i> <a href="{{ basePath }}/{{ repoPath }}:{{ tag }}">{{ tag }}</a>
{{if deleteAllowed}}
<a href="{{ basePath }}/delete-tag?repoPath={{ repoPath }}&tag={{ tag }}" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}} {* end tags *}
{{if eventsAllowed and isset(events) }}
<h4>Recent Activity</h4>
<table id="datatable_events" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Action</th>
<th>Image</th>
<th>IP Address</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{{range _, e := events}}
<tr>
<td>{{ e.Action }}</td>
{{if hasPrefix(e.Tag,"sha256:") }}
<td title="{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</td>
{{else}}
<td>{{ e.Repository }}:{{ e.Tag }}</td>
{{end}}
<td>{{ e.IP }}</td>
<td>{{ e.User }}</td>
<td>{{ e.Created|pretty_time }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}

View File

@ -1,26 +1,63 @@
{{extends "base.html"}} {{extends "base.html"}}
{{import "breadcrumb.html"}}
{{block head()}} {{block head()}}
<script type="text/javascript"> <script type="text/javascript">
$(document).ready(function() { $(document).ready(function() {
$('#datatable').DataTable({ var table = $('#datatable').DataTable({
"pageLength": 10, "pageLength": 10,
"order": [[ 4, 'desc' ]], "order": [[ 4, 'desc' ]],
"stateSave": true, "stateSave": false,
"searchCols": [
null,
{search: $('input:checkbox[name="sha256_chk"]').val()},
],
"language": { "language": {
"emptyTable": "No events." "emptyTable": "No events."
} }
}); });
$.fn.dataTable.ext.search.push(function( settings, searchData, index, rowData, counter ) {
var action = $('input:checkbox[name="action_chk"]:checked').map(function() {
return this.value;
}).get();
if (action.length === 0) {
return true;
}
if (action.indexOf(searchData[0]) !== -1) {
return true;
}
return false;
});
$('input:checkbox[name="action_chk"]').on('change', function () {
table.draw();
});
$('input:checkbox[name="sha256_chk"]').on('change', function () {
if ($(this).prop('checked')) {
table.column(1).search($(this).val()).draw() ;
} else {
table.column(1).search('').draw() ;
}
});
}); });
</script> </script>
{{end}} {{end}}
{{block body()}} {{block body()}}
<ol class="breadcrumb"> <ol class="breadcrumb">
{{ yield breadcrumb() }}
<li class="active">Event Log</li> <li class="active">Event Log</li>
</ol> </ol>
{{if eventsAllowed}} {{if eventsAllowed}}
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="action_chk" value="push">
<label class="form-check-label">Hide Pull</label>
<label class="form-check-label" style="margin-right:10px"></label>
<input class="form-check-input" type="checkbox" name="sha256_chk" value="!@sha256" checked>
<label class="form-check-label">Hide sha256 entries</label>
</div>
<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>
@ -32,13 +69,13 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range e := events}} {{range _, e := events}}
<tr> <tr>
<td>{{ e.Action }}</td> <td>{{ e.Action }}</td>
{{if hasPrefix(e.Tag,"sha256") }} {{if hasPrefix(e.Tag,"sha256:") }}
<td title="{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</td> <td title="{{ e.Tag }}"><a href="{{ basePath }}/{{ e.Repository }}@{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</a></td>
{{else}} {{else}}
<td>{{ e.Repository }}:{{ e.Tag }}</td> <td><a href="{{ basePath }}/{{ e.Repository }}:{{ e.Tag }}">{{ e.Repository }}:{{ e.Tag }}</a></td>
{{end}} {{end}}
<td>{{ e.IP }}</td> <td>{{ e.IP }}</td>
<td>{{ e.User }}</td> <td>{{ e.User }}</td>

90
templates/image_info.html Normal file
View File

@ -0,0 +1,90 @@
{{extends "base.html"}}
{{import "breadcrumb.html"}}
{{import "json_to_table.html"}}
{{block head()}}
<style>
/* col 0 style */
td:nth-child(1) {
color: #838383;
text-align: right;
}
/* td: long line wrap */
td {
word-break: break-word;
}
</style>
{{end}}
{{block body()}}
<ol class="breadcrumb">
{{ yield breadcrumb() ii.ImageRefRepo }}
<li><a href="{{ basePath }}/{{ repoPath }}">{{ ii.ImageRefTag }}</a></li>
</ol>
<h4>
{{if ii.IsImage}}<i class="bi-file-earmark" style="font-size: 2rem;"></i> Image{{end}}
{{if ii.IsImageIndex}}<i class="bi-files" style="font-size: 2rem;"></i> Image Index{{end}}
</h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Summary</th>
</tr>
</thead>
<tr>
<td width="20%"><b>Image Reference</b></td><td>{{ registryHost }}/{{ repoPath }}</td>
</tr>
<tr>
<td><b>Digest</b></td><td><a href="{{ basePath }}/{{ ii.ImageRefRepo }}@{{ ii.ImageRefDigest }}">{{ ii.ImageRefDigest }}</a></td>
</tr>
<tr>
<td><b>Media Type</b></td><td>{{ ii.MediaType }}</td>
</tr>
{{if ii.IsImageIndex}}
<tr>
<td><b>Sub-Images</b></td><td>{{ len(ii.Manifest["manifests"]) }} </td>
</tr>
<tr>
<td><b>Platforms</b></td><td>{{ ii.Platforms }}</td>
</tr>
{{end}}
{{if ii.IsImage}}
<tr>
<td><b>Image ID</b></td><td>{{ ii.ConfigImageID }}</td>
</tr>
<tr>
<td><b>Image Size</b></td><td>{{ ii.ImageSize|pretty_size }}</td>
</tr>
<tr>
<td><b>Platform</b></td><td>{{ ii.Platforms }}</td>
</tr>
<tr>
<td><b>Created On</b></td><td>{{ ii.Created|pretty_time }}</td>
</tr>
{{end}}
</table>
<table class="table" style="margin-bottom: 0">
<thead bgcolor="#ddd">
<tr>
<th>{{if ii.IsImage}}Manifest{{else}}Index Manifest{{end}}</th>
</tr>
</thead>
</table>
{{ yield json_to_table() ii.Manifest }}
{{if ii.IsImage}}
<br>
<table class="table" style="margin-bottom: 0">
<thead bgcolor="#ddd">
<tr>
<th>Config File</th>
</tr>
</thead>
</table>
{{ yield json_to_table() ii.ConfigFile }}
{{end}}
{{end}}

View File

@ -0,0 +1,32 @@
{{ block json_to_table() }}
{{ try }}
<table class="table table-striped table-bordered" style="margin-bottom: 0">
{{range i, k := sort_map_keys(.) }}
{{ v := .[k] }}
<tr>
<td width="15%" style="padding: 2px 8px;">{{k}}</td>
<td style="padding: 2px 8px;">
{{if ii.IsImage && k == "size"}}{{ pretty_size(v) }}
{{else if ii.IsImageIndex && k == "digest"}}<a href="{{ basePath }}/{{ ii.ImageRefRepo }}@{{ v }}">{{ v }}</a>
{{else}}{{ yield json_to_table() v }}{{end}}
</td>
</tr>
{{end}}
</table>
{{ catch err }}
{{if err.Error() == "reflect: call of reflect.Value.MapKeys on slice Value"}}
<table class="table table-striped table-bordered" style="margin-bottom: 0">
{{range _, e := . }}
<tr>
<td style="text-align: left; color: #000; padding: 0px 0px;">{{ yield json_to_table() e }}</td>
</tr>
{{end}}
</table>
{{else}}
{{ . }}
{{end}}
{{end}} {* end try *}
{{ end }}

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

@ -1,140 +0,0 @@
{{extends "base.html"}}
{{block head()}}
<style>
/* col 0 style */
td:nth-child(1) {
color: #838383;
text-align: right;
}
/* td: long line wrap */
td {
word-break: break-word;
}
</style>
{{end}}
{{block body()}}
<ol class="breadcrumb">
<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">Summary</th>
</tr>
</thead>
<tr>
<td width="20%"><b>Image URL</b></td><td>{{ registryHost }}/{{ repoPath }}{{ isDigest ? "@" : ":" }}{{ tag }}</td>
</tr>
<tr>
<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><b>Layer Count</b></td><td>{{ layersCount }}</td>
</tr>
{{end}}
<tr>
<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 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>
<th>Layer #</th>
<th>Digest</th>
<th>Size</th>
</tr>
</thead>
{{range index, layer := layersV2}}
<tr>
<td>{{ len(layersV2)-index }}</td>
<td>{{ layer["digest"] }}</td>
<td>{{ layer["size"]|pretty_size }}</td>
</tr>
{{end}}
</table>
{{end}}
{{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">
<tr>
<th colspan="2">Layer #{{ len(layersV1)-index }}</th>
</tr>
</thead>
{{range key := layer["ordered_keys"]}}
<tr>
<td width="20%">{{ key }}</td>
{{if key == "config" || key == "container_config"}}
<td style="padding: 0">
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
<!-- Nested range does not work. Iterating via filter over the map. -->
{{ layer[key]|parse_map|raw }}
</table>
</td>
{{else if key == "created"}}
<td>{{ layer[key]|pretty_time }}</td>
{{else}}
<td>{{ layer[key] }}</td>
{{end}}
</tr>
{{end}}
</table>
{{end}}
{{end}}
{{end}}

View File

@ -1,92 +0,0 @@
{{extends "base.html"}}
{{block head()}}
<script type="text/javascript" src="{{ basePath }}/static/bootstrap-confirmation.min.js"></script>
<script type="text/javascript" src="{{ basePath }}/static/sorting_natural.js"></script>
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
"pageLength": 10,
"order": [[ 0, 'desc' ]],
"stateSave": true,
columnDefs: [
{ type: 'natural', targets: 0 }
],
"language": {
"emptyTable": "No tags in this repository."
}
})
function populateConfirmation() {
$('[data-toggle=confirmation]').confirmation({
rootSelector: '[data-toggle=confirmation]',
container: 'body'
});
}
populateConfirmation()
$('#datatable').on('draw.dt', populateConfirmation)
});
</script>
{{end}}
{{block body()}}
<ol class="breadcrumb">
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
{{if namespace != "library"}}
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</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>
</tr>
</thead>
<tbody>
{{range tag := tags}}
<tr>
<td>
<a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ 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>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{if eventsAllowed}}
<h4>Latest events on this repo</h4>
<table id="datatable_log" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th>Action</th>
<th>Image</th>
<th>IP Address</th>
<th>User</th>
<th>Time</th>
</tr>
</thead>
<tbody>
{{range e := events}}
<tr>
<td>{{ e.Action }}</td>
{{if hasPrefix(e.Tag,"sha256") }}
<td title="{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</td>
{{else}}
<td>{{ e.Repository }}:{{ e.Tag }}</td>
{{end}}
<td>{{ e.IP }}</td>
<td>{{ e.User }}</td>
<td>{{ e.Created|pretty_time }}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{end}}

View File

@ -1,3 +1,3 @@
package main package main
const version = "0.9.7" const version = "0.10.4"

211
web.go
View File

@ -3,13 +3,12 @@ package main
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/CloudyKit/jet" "github.com/CloudyKit/jet/v6"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry" "github.com/quiq/registry-ui/registry"
"github.com/tidwall/gjson" "github.com/spf13/viper"
) )
const usernameHTTPHeader = "X-WEBAUTH-USER" const usernameHTTPHeader = "X-WEBAUTH-USER"
@ -19,166 +18,90 @@ func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap {
data := jet.VarMap{} data := jet.VarMap{}
data.Set("user", user) data.Set("user", user)
data.Set("eventsAllowed", a.config.AnyoneCanViewEvents || registry.ItemInSlice(user, a.config.Admins)) admins := viper.GetStringSlice("access_control.admins")
data.Set("deleteAllowed", a.config.AnyoneCanDelete || registry.ItemInSlice(user, a.config.Admins)) data.Set("eventsAllowed", viper.GetBool("access_control.anyone_can_view_events") || registry.ItemInSlice(user, admins))
data.Set("deleteAllowed", viper.GetBool("access_control.anyone_can_delete_tags") || registry.ItemInSlice(user, admins))
return data return data
} }
func (a *apiClient) viewRepositories(c echo.Context) error { func (a *apiClient) viewCatalog(c echo.Context) error {
namespace := c.Param("namespace") repoPath := strings.Trim(c.Param("repoPath"), "/")
if namespace == "" { // fmt.Println("repoPath:", repoPath)
namespace = "library"
}
repos := a.client.Repositories(true)[namespace]
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces())
data.Set("repos", repos)
data.Set("tagCounts", a.client.TagCounts())
return c.Render(http.StatusOK, "repositories.html", data)
}
func (a *apiClient) viewTags(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := a.client.Tags(repoPath)
data := a.setUserPermissions(c) data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tags", tags)
repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data)
}
func (a *apiClient) viewTagInfo(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// 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))
}
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() {
imageSize = imageSize + s.Int()
}
} else {
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
}
}
// 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 := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tag", tag)
data.Set("repoPath", repoPath) data.Set("repoPath", repoPath)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
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) showTags := false
showImageInfo := false
allRepoPaths := a.client.GetRepos()
repos := []string{}
if repoPath == "" {
// Show all repos
for _, r := range allRepoPaths {
repos = append(repos, strings.Split(r, "/")[0])
}
} else if strings.Contains(repoPath, ":") {
// Show image info
showImageInfo = true
} else {
for _, r := range allRepoPaths {
if r == repoPath {
// Show tags
showTags = true
}
if strings.HasPrefix(r, repoPath+"/") {
// Show sub-repos
r = strings.TrimPrefix(r, repoPath+"/")
repos = append(repos, strings.Split(r, "/")[0])
}
}
}
if showImageInfo {
// Show image info
imageInfo, err := a.client.GetImageInfo(repoPath)
if err != nil {
basePath := viper.GetString("uri_base_path")
return c.Redirect(http.StatusSeeOther, basePath)
}
data.Set("ii", imageInfo)
return c.Render(http.StatusOK, "image_info.html", data)
} else {
// Show repos, tags or both.
repos = registry.UniqueSortedSlice(repos)
tags := []string{}
if showTags {
tags = a.client.ListTags(repoPath)
}
data.Set("repos", repos)
data.Set("isCatalogReady", a.client.IsCatalogReady())
data.Set("tagCounts", a.client.SubRepoTagCounts(repoPath, repos))
data.Set("tags", tags)
if repoPath != "" && (len(repos) > 0 || len(tags) > 0) {
// Do not show events in the root of catalog.
data.Set("events", a.eventListener.GetEvents(repoPath))
}
return c.Render(http.StatusOK, "catalog.html", data)
}
} }
func (a *apiClient) deleteTag(c echo.Context) error { func (a *apiClient) deleteTag(c echo.Context) error {
namespace := c.Param("namespace") repoPath := c.QueryParam("repoPath")
repo := c.Param("repo") tag := c.QueryParam("tag")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
data := a.setUserPermissions(c) data := a.setUserPermissions(c)
if data["deleteAllowed"].Bool() { if data["deleteAllowed"].Bool() {
a.client.DeleteTag(repoPath, tag) a.client.DeleteTag(repoPath, tag)
} }
basePath := viper.GetString("uri_base_path")
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo)) return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s%s", basePath, repoPath))
} }
// viewLog view events from sqlite. // viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error { func (a *apiClient) viewEventLog(c echo.Context) error {
data := a.setUserPermissions(c) data := a.setUserPermissions(c)
data.Set("events", a.eventListener.GetEvents("")) data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data) return c.Render(http.StatusOK, "event_log.html", data)
} }