Major rewrite with just breaking changes

This commit is contained in:
Roman Vynar 2024-04-16 13:54:18 +03:00
parent f91c3b9aca
commit e334d4c6c7
44 changed files with 1201 additions and 1156 deletions

View File

@ -1,9 +1,25 @@
## Changelog
### UNRELEASED
### 0.10.0 (2024-04-16)
* Add an option to disable counting of tags if it is very slow: `-disable-count-tags`
* Add an option to specify a comma-separated list of repos to purge: `-purge-from-repos`
**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 now 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)

View File

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

View File

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

View File

@ -1,25 +1,27 @@
## 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
* Web UI for Docker Registry
* Browse namespaces, repositories and tags
* Display image details by layers
* Display sub-images of multi-arch or cache type of image
* Support Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 and their confusing combinations
* Fast and small, written on Go
* Automatically discover an authentication method (basic auth, token service etc.)
* Caching the list of repositories, tag counts and refreshing in background
* Event listener of notification events coming from Registry
* 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
* Web UI for Docker Registry or similar alternatives
* Fast, simple and small package
* Browse catalog of repositories and tags
* Show an arbitrary level of repository tree
* Support Docker and OCI image formats
* Support image and image index manifests (multi-platform images)
* Display full information about image index and links to the underlying sub-images
* Display full information about image, its layers and config file (command history)
* Event listener for notification events coming from Registry
* Store events in Sqlite or MySQL database
* 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.
Assuming you will proxy it behind nginx, oauth2_proxy or something.
No TLS or authentication is implemented on the UI instance itself.
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/)
### Configuration
@ -27,14 +29,13 @@ The configuration is stored in `config.yml` and the options are self-descriptive
### Run UI
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro \
--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:
-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
@ -53,8 +54,8 @@ To receive events you need to configure Registry as follow:
notifications:
endpoints:
- name: docker-registry-ui
url: http://docker-registry-ui.local:8000/api/events
- name: registry-ui
url: http://registry-ui.local:8000/event-receiver
headers:
Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890]
timeout: 1s
@ -64,7 +65,7 @@ To receive events you need to configure Registry as follow:
- application/octet-stream
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
@ -94,46 +95,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
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:
docker exec -t registry-ui /opt/docker-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.
docker exec -t registry-ui /opt/registry-ui -purge-tags -dry-run
### Screenshots
Repository list / home page:
Repository list:
![image](screenshots/1.png)
Repository tag list:
Tag list:
![image](screenshots/2.png)
Tag info page:
Image Index info:
![image](screenshots/3.png)
Event log page:
Image info:
![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,84 @@
# Listen interface.
listen_addr: 0.0.0.0:8000
# Base path of Docker Registry UI.
base_path: /
# Registry URL with schema and port.
registry_url: https://docker-registry.local
# Verify TLS certificate when using https.
verify_tls: true
# Base path of Registry UI.
uri_base_path: /
# Docker registry credentials.
# They need to have a full access to the registry.
# If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens.
# 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
# Background tasks.
performance:
# Catalog list page size. It depends from the underlying storage performance.
catalog_page_size: 100
# Event listener token.
# The same one should be configured on Docker registry as Authorization Bearer token.
event_listener_token: token
# Retention of records to keep.
event_retention_days: 7
# Catalog (repo list) refresh interval in minutes.
# If set to 0 it will never refresh but will run once.
catalog_refresh_interval: 10
# Event listener storage.
event_database_driver: sqlite3
event_database_location: data/registry_events.db
# event_database_driver: mysql
# event_database_location: user:password@tcp(localhost:3306)/docker_events
# Tags counting refresh interval in minutes.
# If set to 0 it will never run. This is fast operation.
tags_count_refresh_interval: 60
# You can disable event deletion on some hosts when you are running docker-registry on master-master or
# cluster setup to avoid deadlocks or replication break.
event_deletion_enabled: true
# Registry endpoint and authentication.
registry:
# Registry hostname (without protocol but may include port).
hostname: docker-registry.local
# Cache refresh interval in minutes.
# How long to cache repository list and tag counts.
cache_refresh_interval: 10
# Registry credentials.
# They need to have a full access to the registry.
# If token authentication service is enabled, it will be auto-discovered and those credentials
# will be used to obtain access tokens.
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.
anyone_can_view_events: true
# If all users can delete tags. If set to false, then only admins listed below.
anyone_can_delete: false
# Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy.
admins: []
# Alternatively, you can do auth with Keychain, useful for local development.
# When enabled the above credentials will not be used.
auth_with_keychain: false
# Debug mode. Affects only templates.
debug: true
# UI access management.
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.
purge_tags_keep_days: 90
purge_tags_keep_count: 2
# Event listener configuration.
event_listener:
# 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$'
# Empty string disables this feature.
purge_tags_keep_regexp: ''
# Event listener storage.
database_driver: sqlite3
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.
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
# Empty string disables this feature.
purge_tags_keep_from_file: ''
# You can disable event deletion on some hosts when you are running registry UI on MySQL master-master or
# cluster setup to avoid deadlocks or replication breaks.
deletion_enabled: true
# Enable built-in cron to schedule purging tags in server mode.
# Empty string disables this feature.
# Example: '25 54 17 * * *' will run it at 17:54:25 daily.
# Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
purge_tags_schedule: ''
# Options for tag purging.
purge_tags:
# How many days to keep tags but also keep the minimal count provided no matter how old.
keep_days: 90
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

@ -8,8 +8,9 @@ import (
"os"
"strings"
"github.com/quiq/docker-registry-ui/registry"
"github.com/quiq/registry-ui/registry"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
// 🐒 patching of "database/sql".
_ "github.com/go-sql-driver/mysql"
@ -18,6 +19,7 @@ import (
)
const (
userAgent = "registry-ui"
schemaSQLite = `
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -56,7 +58,16 @@ type EventRow struct {
}
// 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{
databaseDriver: databaseDriver,
databaseLocation: databaseLocation,
@ -90,8 +101,8 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
}
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
for _, i := range gjson.GetBytes(j, "events").Array() {
// Ignore calls by docker-registry-ui itself.
if i.Get("request.useragent").String() == "docker-registry-ui" {
// Ignore calls by registry-ui itself.
if strings.HasPrefix(i.Get("request.useragent").String(), userAgent) {
continue
}
action := i.Get("action").String()
@ -143,7 +154,8 @@ func (e *EventListener) GetEvents(repository string) []EventRow {
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
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)
if err != nil {

56
go.mod
View File

@ -1,39 +1,67 @@
module github.com/quiq/docker-registry-ui
module github.com/quiq/registry-ui
go 1.22.1
require (
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible
github.com/go-sql-driver/mysql v1.7.1
github.com/CloudyKit/jet/v6 v6.2.0
github.com/fatih/color v1.15.0
github.com/go-sql-driver/mysql v1.8.1
github.com/google/go-containerregistry v0.19.1
github.com/labstack/echo/v4 v4.11.4
github.com/mattn/go-sqlite3 v1.14.22
github.com/parnurzeal/gorequest v0.2.16
github.com/robfig/cron v1.2.0
github.com/sirupsen/logrus v1.9.3
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/viper v1.18.2
github.com/tidwall/gjson v1.17.1
gopkg.in/yaml.v2 v2.4.0
)
require (
filippo.io/edwards25519 v1.1.0 // 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.15.1 // indirect
github.com/docker/cli v26.0.0+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v26.0.0+incompatible // indirect
github.com/docker/docker-credential-helpers v0.8.1 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gopherjs/gopherjs v1.17.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/klauspost/compress v1.17.8 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/smarty/assertions v1.15.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // 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/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/sys v0.15.0 // indirect
github.com/vbatts/tar-split v0.11.5 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.22.0 // indirect
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 // indirect
golang.org/x/net v0.24.0 // indirect
golang.org/x/sync v0.7.0 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
moul.io/http2curl v1.0.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
go 1.19

126
go.sum
View File

@ -1,25 +1,55 @@
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/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible h1:rZgFj+Gtf3NMi/U5FvCvhzaxzW/TaPYgUYx3bAPz9DE=
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME=
github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4=
github.com/containerd/stargz-snapshotter/estargz v0.15.1 h1:eXJjw9RbkLFgioVaTG+G/ZW/0kEe2oEKCdS/ZxIyoCU=
github.com/containerd/stargz-snapshotter/estargz v0.15.1/go.mod h1:gr2RNwukQ/S9Nv33Lt6UC7xEx58C+LHRdoqbEKjz1Kk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M=
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docker/cli v26.0.0+incompatible h1:90BKrx1a1HKYpSnnBFR6AgDq/FqkHxwlUyzJVPxD30I=
github.com/docker/cli v26.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v26.0.0+incompatible h1:Ng2qi+gdKADUa/VM+6b6YaY2nlZhk/lVJiKR/2bMudU=
github.com/docker/docker v26.0.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.8.1 h1:j/eKUktUltBtMzKqmfLB0PAgqYyMHOp5vfsD1807oKo=
github.com/docker/docker-credential-helpers v0.8.1/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
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.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY=
github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
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/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
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/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
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.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -27,52 +57,94 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
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.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
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.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
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.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
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.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
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.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/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/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
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=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts=
github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8 h1:ESSUROHIBHg7USnszlcdmjBEwdMj9VUvU+OPk4yl2mc=
golang.org/x/exp v0.0.0-20240409090435-93d18d7e34b8/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=

102
main.go
View File

@ -3,35 +3,36 @@ package main
import (
"flag"
"fmt"
"net/url"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"github.com/quiq/docker-registry-ui/events"
"github.com/quiq/docker-registry-ui/registry"
"github.com/robfig/cron"
"github.com/quiq/registry-ui/events"
"github.com/quiq/registry-ui/registry"
"github.com/sirupsen/logrus"
"github.com/spf13/viper"
)
type apiClient struct {
client *registry.Client
eventListener *events.EventListener
config *configData
}
func main() {
var (
a apiClient
configFile, loggingLevel, purgeFromRepos string
disableCountTags, purgeTags, purgeDryRun bool
configFile, loggingLevel string
purgeFromRepos string
purgeTags, purgeDryRun bool
)
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
flag.StringVar(&loggingLevel, "log-level", "info", "logging level")
flag.BoolVar(&disableCountTags, "disable-count-tags", false, "disable counting of tags if it is very slow")
flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server")
flag.StringVar(&purgeFromRepos, "purge-from-repos", "", "comma-separated list of repos to purge instead of all")
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
flag.StringVar(&purgeFromRepos, "purge-from-repos", "", "comma-separated list of repos to purge instead of all")
flag.Parse()
// Setup logging
@ -42,72 +43,61 @@ func main() {
}
// Read config file
a.config = readConfig(configFile)
a.config.PurgeConfig.DryRun = purgeDryRun
viper.SetConfigName(strings.Split(filepath.Base(configFile), ".")[0])
viper.AddConfigPath(filepath.Dir(configFile))
viper.AddConfigPath(".")
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("fatal error reading config file: %w", err))
}
// Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
if a.client == nil {
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
}
purgeFunc := func() {
registry.PurgeOldTags(a.client, a.config.PurgeConfig, purgeFromRepos)
}
a.client = registry.NewClient()
// Execute CLI task and exit.
if purgeTags {
purgeFunc()
registry.PurgeOldTags(a.client, purgeDryRun, purgeFromRepos)
return
}
// Schedules to purge tags.
if a.config.PurgeTagsSchedule != "" {
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.
if !disableCountTags {
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,
)
go a.client.StartBackgroundJobs()
a.eventListener = events.NewEventListener()
// Template engine init.
e := echo.New()
registryHost, _ := url.Parse(a.config.RegistryURL) // validated already in config.go
e.Renderer = setupRenderer(a.config.Debug, registryHost.Host, a.config.BasePath)
// e.Use(middleware.Logger())
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.
e.File("/favicon.ico", "static/favicon.ico")
e.Static(a.config.BasePath+"/static", "static")
if a.config.BasePath != "" {
e.GET(a.config.BasePath, a.viewRepositories)
e.Static(basePath+"/static", "static")
p := e.Group(basePath)
if basePath != "" {
e.GET(basePath, a.viewCatalog)
}
e.GET(a.config.BasePath+"/", a.viewRepositories)
e.GET(a.config.BasePath+"/:namespace", a.viewRepositories)
e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo)
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
e.GET(a.config.BasePath+"/events", a.viewLog)
p.GET("/", a.viewCatalog)
p.GET("/:repoPath", a.viewCatalog)
p.GET("/event-log", a.viewEventLog)
p.GET("/delete-tag", a.deleteTag)
// Protected event listener.
p := e.Group(a.config.BasePath + "/api")
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
pp := e.Group("/event-receiver")
pp.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
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,289 +1,301 @@
package registry
import (
"crypto"
"crypto/tls"
"fmt"
"regexp"
"sort"
"context"
"encoding/json"
"os"
"strings"
"sync"
"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/tidwall/gjson"
"github.com/spf13/viper"
)
const userAgent = "docker-registry-ui"
var paginationRegex = regexp.MustCompile("^<(.*?)>;.*$")
const userAgent = "registry-ui"
// Client main class.
type Client struct {
url string
verifyTLS bool
username string
password string
request *gorequest.SuperAgent
logger *logrus.Entry
mux sync.Mutex
tokens map[string]string
repos map[string][]string
tagCounts map[string]int
authURL string
puller *remote.Puller
pusher *remote.Pusher
logger *logrus.Entry
repos []string
tagCountsMux sync.Mutex
tagCounts map[string]int
isCatalogReady bool
}
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.
func NewClient(url string, verifyTLS bool, username, password string) *Client {
c := &Client{
url: strings.TrimRight(url, "/"),
verifyTLS: verifyTLS,
username: username,
password: password,
func NewClient() *Client {
var authOpt remote.Option
if viper.GetBool("registry.auth_with_keychain") {
authOpt = remote.WithAuthFromKeychain(authn.DefaultKeychain)
} else {
password := viper.GetString("registry.password")
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")
}
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
authOpt = remote.WithAuth(authn.FromConfig(authn.AuthConfig{
Username: viper.GetString("registry.username"), Password: password,
}))
}
pageSize := viper.GetInt("performance.catalog_page_size")
puller, _ := remote.NewPuller(authOpt, remote.WithUserAgent(userAgent), remote.WithPageSize(pageSize))
pusher, _ := remote.NewPusher(authOpt, remote.WithUserAgent(userAgent))
c := &Client{
puller: puller,
pusher: pusher,
logger: SetupLogging("registry.client"),
tokens: map[string]string{},
repos: map[string][]string{},
repos: []string{},
tagCounts: map[string]int{},
}
resp, _, errs := c.request.Get(c.url+"/v2/").
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 {
c.logger.Error(resp.Status)
return nil
}
if strings.HasPrefix(authHeader, "Bearer") {
r, _ := regexp.Compile(`^Bearer realm="(http.+)",service="(.+)"`)
if m := r.FindStringSubmatch(authHeader); len(m) > 0 {
c.authURL = fmt.Sprintf("%s?service=%s", m[1], m[2])
c.logger.Info("Token auth service discovered at ", c.authURL)
}
if c.authURL == "" {
c.logger.Warn("No token auth service discovered from ", c.url)
return nil
}
} else if strings.HasPrefix(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.")
}
return c
}
// getToken get existing or new auth token.
func (c *Client) getToken(scope string) string {
// Check if we have already a token and it's not expired.
if token, ok := c.tokens[scope]; ok {
resp, _, _ := c.request.Get(c.url+"/v2/").
Set("Authorization", fmt.Sprintf("Bearer %s", token)).
Set("User-Agent", 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) {
// TODO Support OCI manifest https://github.com/opencontainers/image-spec/blob/main/manifest.md
// acceptHeader := "application/vnd.oci.image.manifest.v1+json"
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()
scope := "registry:catalog:*"
uri := "/v2/_catalog"
tmp := map[string][]string{}
count := 0
func (c *Client) StartBackgroundJobs() {
catalogInterval := viper.GetInt("performance.catalog_refresh_interval")
tagsCountInterval := viper.GetInt("performance.tags_count_refresh_interval")
isStarted := false
for {
data, resp := c.callRegistry(uri, scope, "manifest.v2")
if data == "" {
c.RefreshCatalog()
if !isStarted && tagsCountInterval > 0 {
// Start after the first catalog refresh
go c.CountTags(tagsCountInterval)
isStarted = true
}
if catalogInterval == 0 {
c.logger.Warn("Catalog refresh is disabled in the config and will not run anymore.")
break
}
time.Sleep(time.Duration(catalogInterval) * time.Minute)
}
for _, r := range gjson.Get(data, "repositories").Array() {
namespace, repo := SplitRepoPath(r.String())
tmp[namespace] = append(tmp[namespace], repo)
count++
}
func (c *Client) RefreshCatalog() {
ctx := context.Background()
start := time.Now()
c.logger.Info("[RefreshCatalog] Started reading catalog...")
registry, _ := name.NewRegistry(viper.GetString("registry.hostname"))
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)
}
// pagination
linkHeader := resp.Header.Get("Link")
link := paginationRegex.FindStringSubmatch(linkHeader)
if len(link) == 2 {
// update uri and query next page
uri = link[1]
} else {
// no more pages
break
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)
}
}
}
c.repos = tmp
c.logger.Debugf("Refreshed the catalog of %d repositories.", count)
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
}
// Tags get tags for the repo.
func (c *Client) Tags(repo string) []string {
scope := fmt.Sprintf("repository:%s:*", repo)
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, "manifest.v2")
var tags []string
for _, t := range gjson.Get(data, "tags").Array() {
tags = append(tags, t.String())
// ListTags get tags for the repo
func (c *Client) ListTags(repoName string) []string {
ctx := context.Background()
repo, _ := name.NewRepository(viper.GetString("registry.hostname") + "/" + repoName)
tags, err := c.puller.List(ctx, repo)
if err != nil {
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
}
// ManifestList gets manifest list entries for a tag for the repo.
func (c *Client) ManifestList(repo, tag string) (string, []gjson.Result) {
scope := fmt.Sprintf("repository:%s:*", repo)
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
// If manifest.list.v2 does not exist because it's a normal image,
// the registry returns manifest.v1 or manifest.v2 if requested by sha256.
info, resp := c.callRegistry(uri, scope, "manifest.list.v2")
digest := resp.Header.Get("Docker-Content-Digest")
sha256 := ""
if digest != "" {
sha256 = digest[7:]
// GetImageInfo get image info by the reference - tag name or digest sha256.
func (c *Client) GetImageInfo(imageRef string) (ImageInfo, error) {
ctx := context.Background()
ref, err := name.ParseReference(viper.GetString("registry.hostname") + "/" + imageRef)
if err != nil {
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
return ImageInfo{}, err
}
c.logger.Debugf(`Received manifest.list.v2 with sha256 "%s" from %s: %s`, sha256, uri, info)
return sha256, gjson.Get(info, "manifests").Array()
descr, err := c.puller.Get(ctx, ref)
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, _ := descr.Image()
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, _ := descr.ImageIndex()
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 (c *Client) TagInfo(repo, tag string, v1only bool) (string, string, string) {
scope := fmt.Sprintf("repository:%s:*", repo)
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
// Note, if manifest.v1 does not exist because the image is requested by sha256,
// the registry returns manifest.v2 instead or manifest.list.v2 if it's the manifest list!
infoV1, _ := c.callRegistry(uri, scope, "manifest.v1")
c.logger.Debugf("Received manifest.v1 from %s: %s", uri, infoV1)
if infoV1 == "" || v1only {
return "", infoV1, ""
func getPlatform(p *v1.Platform) string {
if p != nil {
return p.String()
}
// 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
return ""
}
// TagCounts return map with tag counts.
func (c *Client) TagCounts() map[string]int {
return c.tagCounts
// structToMap convert struct to map so it can be formatted as HTML table easily
func structToMap(obj interface{}) map[string]interface{} {
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)
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, _ := descr.Image()
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
}
// TagCounts return map with tag counts according to the provided list of repos/sub-repos etc.
func (c *Client) TagCounts(repoPath string, repos []string) map[string]int {
counts := map[string]int{}
for _, r := range repos {
subRepo := r
if repoPath != "" {
subRepo = repoPath + "/" + r
}
for k, v := range c.tagCounts {
if strings.HasPrefix(k, subRepo) {
counts[subRepo] = counts[subRepo] + v
}
}
}
return counts
}
// CountTags count repository tags in background regularly.
func (c *Client) CountTags(interval uint8) {
func (c *Client) CountTags(interval int) {
for {
start := time.Now()
c.logger.Info("[CountTags] Calculating image tags...")
catalog := c.Repositories(false)
for n, repos := range catalog {
for _, r := range repos {
repoPath := r
if n != "library" {
repoPath = fmt.Sprintf("%s/%s", n, r)
}
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
}
c.logger.Info("[CountTags] Started counting tags...")
for _, r := range c.repos {
c.ListTags(r)
}
c.logger.Infof("[CountTags] Job complete (%v).", time.Since(start))
time.Sleep(time.Duration(interval) * time.Minute)
@ -291,33 +303,37 @@ func (c *Client) CountTags(interval uint8) {
}
// DeleteTag delete image tag.
func (c *Client) DeleteTag(repo, tag string) {
scope := fmt.Sprintf("repository:%s:*", repo)
// Get sha256 digest for tag.
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2")
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" {
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2")
func (c *Client) DeleteTag(repoPath, tag string) {
ctx := context.Background()
imageRef := repoPath + ":" + tag
ref, err := name.ParseReference(viper.GetString("registry.hostname") + "/" + imageRef)
if err != nil {
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
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)
if err != nil {
c.logger.Errorf("Error parsing image reference %s: %s", imageRefDigest, err)
return
}
// Delete by manifest digest reference.
authHeader := ""
if c.authURL != "" {
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
}
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest"))
resp, _, errs := c.request.Delete(c.url+uri).
Set("Authorization", authHeader).
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
} else {
// Returns 202 on success.
if !strings.Contains(repo, "/") {
c.tagCounts["library/"+repo]--
} else {
c.tagCounts[repo]--
}
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)
// Delete tag using digest.
// Note, it will also delete any other tags pointing to the same digest!
err = c.pusher.Delete(ctx, ref)
if err != nil {
c.logger.Errorf("Error deleting image %s: %s", imageRef, err)
return
}
c.tagCountsMux.Lock()
c.tagCounts[repoPath]--
c.tagCountsMux.Unlock()
c.logger.Infof("Image %s has been successfully deleted.", imageRef)
}

View File

@ -5,7 +5,6 @@ import (
"os"
"reflect"
"sort"
"strings"
"time"
"github.com/sirupsen/logrus"
@ -60,14 +59,18 @@ func ItemInSlice(item string, slice []string) bool {
return false
}
// Sprit repo path by namespace and repo name
func SplitRepoPath(repoPath string) (string, string) {
namespace := "library"
repo := repoPath
if strings.Contains(repoPath, "/") {
f := strings.SplitN(repoPath, "/", 2)
namespace = f[0]
repo = f[1]
// 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 namespace, repo
return slice[:j]
}

View File

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

View File

@ -9,27 +9,20 @@ import (
"strings"
"time"
"github.com/spf13/viper"
"github.com/tidwall/gjson"
)
type PurgeTagsConfig struct {
DryRun bool
KeepDays int
KeepMinCount int
KeepTagRegexp string
KeepFromFile string
}
type tagData struct {
type TagData struct {
name string
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"))
}
type timeSlice []tagData
type timeSlice []TagData
func (p timeSlice) Len() int {
return len(p)
@ -37,7 +30,7 @@ func (p timeSlice) Len() int {
func (p timeSlice) Less(i, j int) bool {
// 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) {
return p[i].name > p[j].name
}
@ -49,81 +42,74 @@ func (p timeSlice) Swap(i, j int) {
}
// PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, config *PurgeTagsConfig, purgeFromRepos string) {
func PurgeOldTags(client *Client, purgeDryRun bool, purgeFromRepos string) {
logger := SetupLogging("registry.tasks.PurgeOldTags")
var keepTagsFromFile gjson.Result
if config.KeepFromFile != "" {
if _, err := os.Stat(config.KeepFromFile); os.IsNotExist(err) {
logger.Warnf("Cannot open %s: %s", config.KeepFromFile, err)
var dataFromFile gjson.Result
keepFromFile := viper.GetString("purge_tags.keep_from_file")
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(config.KeepFromFile)
data, err := os.ReadFile(keepFromFile)
if err != nil {
logger.Warnf("Cannot read %s: %s", config.KeepFromFile, err)
logger.Warnf("Cannot read %s: %s", keepFromFile, err)
logger.Error("Not purging anything!")
return
}
keepTagsFromFile = gjson.ParseBytes(data)
dataFromFile = gjson.ParseBytes(data)
}
dryRunText := ""
if config.DryRun {
if purgeDryRun {
logger.Warn("Dry-run mode enabled.")
dryRunText = "skipped"
}
catalog := map[string][]string{}
catalog := []string{}
if purgeFromRepos != "" {
logger.Infof("Working on repositories [%s] to scan their tags and creation dates...", purgeFromRepos)
for _, p := range strings.Split(purgeFromRepos, ",") {
namespace, repo := SplitRepoPath(p)
catalog[namespace] = append(catalog[namespace], repo)
}
catalog = append(catalog, strings.Split(purgeFromRepos, ",")...)
} else {
logger.Info("Scanning registry for repositories, tags and their creation dates...")
catalog = client.Repositories(true)
client.RefreshCatalog()
catalog = client.GetRepos()
}
now := time.Now().UTC()
repos := map[string]timeSlice{}
count := 0
for namespace := range catalog {
count = count + len(catalog[namespace])
for _, repo := range catalog[namespace] {
if namespace != "library" {
repo = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := client.Tags(repo)
if len(tags) == 0 {
for _, repo := range catalog {
tags := client.ListTags(repo)
if len(tags) == 0 {
continue
}
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
for _, tag := range tags {
imageRef := repo + ":" + tag
created := client.GetImageCreated(imageRef)
if created.IsZero() {
// Image manifest with zero creation time, e.g. cosign one
logger.Debugf("[%s] tag with zero creation time: %s", repo, tag)
continue
}
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
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()
if created.IsZero() {
// OCI manifest w/o creation time or any other case with zero time
continue
}
repos[repo] = append(repos[repo], tagData{name: tag, created: created})
}
repos[repo] = append(repos[repo], TagData{name: tag, created: created})
}
}
logger.Infof("Scanned %d repositories.", count)
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", config.KeepDays, config.KeepMinCount)
if config.KeepTagRegexp != "" {
logger.Infof("Keeping tags matching regexp: %s", config.KeepTagRegexp)
logger.Infof("Scanned %d repositories.", len(catalog))
keepDays := viper.GetInt("purge_tags.keep_days")
keepCount := viper.GetInt("purge_tags.keep_count")
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", keepDays, keepCount)
keepRegexp := viper.GetString("purge_tags.keep_regexp")
if keepRegexp != "" {
logger.Infof("Keeping tags matching regexp: %s", keepRegexp)
}
if config.KeepFromFile != "" {
logger.Infof("Keeping tags for repos from the file: %+v", keepTagsFromFile)
if keepFromFile != "" {
logger.Infof("Keeping tags for repos from the file: %+v", dataFromFile)
}
purgeTags := map[string][]string{}
keepTags := map[string][]string{}
@ -134,19 +120,19 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig, purgeFromRepos string
// Prep the list of tags to preserve if defined in the file
tagsFromFile := []string{}
for _, i := range keepTagsFromFile.Get(repo).Array() {
for _, i := range dataFromFile.Get(repo).Array() {
tagsFromFile = append(tagsFromFile, i.String())
}
// Filter out tags
for _, tag := range repos[repo] {
daysOld := int(now.Sub(tag.created).Hours() / 24)
keepByRegexp := false
if config.KeepTagRegexp != "" {
keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name)
matchByRegexp := false
if keepRegexp != "" {
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)
} else {
keepTags[repo] = append(keepTags[repo], tag.name)
@ -154,9 +140,9 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig, purgeFromRepos string
}
// 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".
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]...)
purgeTags[repo] = purgeTags[repo][takeFromPurge:]
}
@ -177,7 +163,7 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig, purgeFromRepos string
continue
}
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
if config.DryRun {
if purgeDryRun {
continue
}
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 (
"fmt"
"io"
"net/url"
"time"
"github.com/CloudyKit/jet"
"github.com/CloudyKit/jet/v6"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
"github.com/quiq/registry-ui/registry"
"github.com/spf13/viper"
)
// Template Jet template.
@ -35,45 +34,45 @@ func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
}
// setupRenderer template engine init.
func setupRenderer(debug bool, registryHost, basePath string) *Template {
view := jet.NewHTMLSet("templates")
view.SetDevelopmentMode(debug)
func setupRenderer(basePath string) *Template {
var opts []jet.Option
if viper.GetBool("debug.templates") {
opts = append(opts, jet.InDevelopmentMode())
}
view := jet.NewSet(jet.NewOSFileSystemLoader("templates"), opts...)
view.AddGlobal("version", version)
view.AddGlobal("basePath", basePath)
view.AddGlobal("registryHost", registryHost)
view.AddGlobal("pretty_size", func(size interface{}) string {
var value float64
switch i := size.(type) {
case gjson.Result:
value = float64(i.Int())
view.AddGlobal("registryHost", viper.GetString("registry.hostname"))
view.AddGlobal("pretty_size", func(val interface{}) string {
var s float64
switch i := val.(type) {
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 {
t, err := time.Parse("2006-01-02T15:04:05Z", timeVal.(string))
if err != nil {
// mysql case
t, _ = time.Parse("2006-01-02 15:04:05", timeVal.(string))
view.AddGlobal("pretty_time", func(val interface{}) 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")
})
view.AddGlobal("parse_map", func(m interface{}) string {
var res string
for _, k := range registry.SortedMapKeys(m) {
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
}
return res
view.AddGlobal("sort_map_keys", func(m interface{}) []string {
return registry.SortedMapKeys(m)
})
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}
}

View File

@ -4,19 +4,20 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Docker Registry UI</title>
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/datatables.min.css"/>
<script type="text/javascript" src="{{ basePath }}/static/datatables.min.js"></script>
<title>Registry UI</title>
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/css/bootstrap-icons.min.css">
<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()}}
</head>
<body>
<div class="container">
<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>
{{if eventsAllowed}}
<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>
{{end}}
<div style="clear: both"></div>
@ -25,7 +26,7 @@
<div style="padding: 10px 0; margin-bottom: 20px">
<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>
</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>Latest 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"}}
{{import "breadcrumb.html"}}
{{block head()}}
<script type="text/javascript">
$(document).ready(function() {
$('#datatable').DataTable({
var table = $('#datatable').DataTable({
"pageLength": 10,
"order": [[ 4, 'desc' ]],
"stateSave": true,
"stateSave": false,
"searchCols": [
null,
{search: $('input:checkbox[name="sha256_chk"]').val()},
],
"language": {
"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>
{{end}}
{{block body()}}
<ol class="breadcrumb">
{{ yield breadcrumb() }}
<li class="active">Event Log</li>
</ol>
{{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">
<thead bgcolor="#ddd">
<tr>
@ -32,7 +69,7 @@
</tr>
</thead>
<tbody>
{{range e := events}}
{{range _, e := events}}
<tr>
<td>{{ e.Action }}</td>
{{if hasPrefix(e.Tag,"sha256") }}

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 && layersV1}}
<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
const version = "0.9.7"
const version = "0.10.0"

211
web.go
View File

@ -3,13 +3,12 @@ package main
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/CloudyKit/jet"
"github.com/CloudyKit/jet/v6"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
"github.com/quiq/registry-ui/registry"
"github.com/spf13/viper"
)
const usernameHTTPHeader = "X-WEBAUTH-USER"
@ -19,166 +18,90 @@ func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap {
data := jet.VarMap{}
data.Set("user", user)
data.Set("eventsAllowed", a.config.AnyoneCanViewEvents || registry.ItemInSlice(user, a.config.Admins))
data.Set("deleteAllowed", a.config.AnyoneCanDelete || registry.ItemInSlice(user, a.config.Admins))
admins := viper.GetStringSlice("access_control.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
}
func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace")
if namespace == "" {
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)
func (a *apiClient) viewCatalog(c echo.Context) error {
repoPath := strings.Trim(c.Param("repoPath"), "/")
// fmt.Println("repoPath:", repoPath)
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("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.TagCounts(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 {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
repoPath := c.QueryParam("repoPath")
tag := c.QueryParam("tag")
data := a.setUserPermissions(c)
if data["deleteAllowed"].Bool() {
a.client.DeleteTag(repoPath, tag)
}
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
basePath := viper.GetString("uri_base_path")
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s%s", basePath, repoPath))
}
// 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.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data)
}