Compare commits
19 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
e712ae59d2 | ||
|
7e1cff804f | ||
|
c4a70ba1df | ||
|
b8faa4b9b1 | ||
|
628d28398f | ||
|
674562d8d7 | ||
|
920e4132f0 | ||
|
6dc1408576 | ||
|
1af4694889 | ||
|
bbefd03dbd | ||
|
f7e40bece8 | ||
|
b49076db7c | ||
|
c7c3a815fb | ||
|
929daf733f | ||
|
86ee1d56bd | ||
|
d29c24a78f | ||
|
e334d4c6c7 | ||
|
f91c3b9aca | ||
|
8a48bd4e8b |
48
CHANGELOG.md
@ -1,5 +1,53 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
## 0.10.4 (2025-06-11)
|
||||||
|
|
||||||
|
* Include the default config file into the Docker image.
|
||||||
|
* Upgrade go version to 1.24.4 and all dependencies, alpine to 3.21.
|
||||||
|
|
||||||
|
## 0.10.3 (2024-08-15)
|
||||||
|
|
||||||
|
* Add `registry.insecure` option to the config (alternatively REGISTRY_INSECURE env var) to support non-https registries.
|
||||||
|
Thanks to @KanagawaNezumi
|
||||||
|
* Fix concurrent map iteration and write in rare cases.
|
||||||
|
* Upgrade go version to 1.22.6 and all dependencies, alpine to 3.20.
|
||||||
|
* IPv6 addresses were not displayed correctly.
|
||||||
|
In case you need to store registry events with IPv6 addresses in MySQL, you need to run `ALTER TABLE events MODIFY column ip varchar(45) NULL`.
|
||||||
|
For sqlite, you can start a new db file or migrate events manually as it doesn't support ALTER.
|
||||||
|
|
||||||
|
## 0.10.2 (2024-05-31)
|
||||||
|
|
||||||
|
* Fix repo tag count when a repo name is a prefix for another repo name(s)
|
||||||
|
* Allow to override any config option via environment variables using SECTION_KEY_NAME syntax, e.g.
|
||||||
|
LISTEN_ADDR, PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL, REGISTRY_HOSTNAME etc.
|
||||||
|
|
||||||
|
## 0.10.1 (2024-04-19)
|
||||||
|
|
||||||
|
* Rename cmd flag `-purge-from-repos` to `-purge-include-repos` to purge tags only for the specified repositories.
|
||||||
|
* Add a new cmd flag `-purge-exclude-repos` to skip the specified repositories from the tag purging.
|
||||||
|
* Make image column clickable in Event Log.
|
||||||
|
|
||||||
|
### 0.10.0 (2024-04-16)
|
||||||
|
|
||||||
|
**JUST BREAKING CHANGES**
|
||||||
|
|
||||||
|
* We have made a full rewrite. Over 6 years many things have been changed.
|
||||||
|
* Renamed github/dockerhub repo from docker-registry-ui -> registry-ui
|
||||||
|
* Switched from doing raw http calls to `github.com/google/go-containerregistry`
|
||||||
|
* URLs and links are now matching the image references, no more "library" or other weird URL parts.
|
||||||
|
* No namespace or only 2-level deep concept
|
||||||
|
* An arbitrary repository levels are supported
|
||||||
|
* It is even possible to list both sub-repos and tags within the same repo path if you have those
|
||||||
|
* Added support for OCI images, so now both Docker + OCI are supported
|
||||||
|
* Proper support of Image Index (Index Manifest)
|
||||||
|
* Display full information available about Image or Image Index
|
||||||
|
* Sub-images (multi-platform ones) are linked under Image Index
|
||||||
|
* Changed format of config.yml but the same concept is preserved
|
||||||
|
* Event listener path has been changed from /api/events to /event-receiver and you may need to update your registry config
|
||||||
|
* Removed built-in cron scheduler for purging tags, please use the normal cron :)
|
||||||
|
* Now you can tune the refresh of catalog and separately refresh of tag counting, disable them etc.
|
||||||
|
* Everything has been made better! :)
|
||||||
|
|
||||||
### 0.9.7 (2024-02-21)
|
### 0.9.7 (2024-02-21)
|
||||||
|
|
||||||
* Fix timezone support: now when running a container with `TZ` env var, e.g. "-e TZ=America/Los_Angeles", it will be reflected everywhere on UI.
|
* Fix timezone support: now when running a container with `TZ` env var, e.g. "-e TZ=America/Los_Angeles", it will be reflected everywhere on UI.
|
||||||
|
11
Dockerfile
@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.22.0-alpine3.19 as builder
|
FROM golang:1.24.4-alpine3.21 as builder
|
||||||
|
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add ca-certificates git bash gcc musl-dev
|
apk add ca-certificates git bash gcc musl-dev
|
||||||
@ -9,10 +9,10 @@ ADD registry registry
|
|||||||
ADD *.go go.mod go.sum ./
|
ADD *.go go.mod go.sum ./
|
||||||
|
|
||||||
RUN go test -v ./registry && \
|
RUN go test -v ./registry && \
|
||||||
go build -o /opt/docker-registry-ui *.go
|
go build -o /opt/registry-ui *.go
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:3.19
|
FROM alpine:3.21
|
||||||
|
|
||||||
WORKDIR /opt
|
WORKDIR /opt
|
||||||
RUN apk add --no-cache ca-certificates tzdata && \
|
RUN apk add --no-cache ca-certificates tzdata && \
|
||||||
@ -21,7 +21,8 @@ RUN apk add --no-cache ca-certificates tzdata && \
|
|||||||
|
|
||||||
ADD templates /opt/templates
|
ADD templates /opt/templates
|
||||||
ADD static /opt/static
|
ADD static /opt/static
|
||||||
COPY --from=builder /opt/docker-registry-ui /opt/
|
ADD config.yml /opt
|
||||||
|
COPY --from=builder /opt/registry-ui /opt/
|
||||||
|
|
||||||
USER nobody
|
USER nobody
|
||||||
ENTRYPOINT ["/opt/docker-registry-ui"]
|
ENTRYPOINT ["/opt/registry-ui"]
|
||||||
|
22
Makefile
@ -1,10 +1,20 @@
|
|||||||
IMAGE=quiq/docker-registry-ui
|
IMAGE=quiq/registry-ui
|
||||||
VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go`
|
VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go`
|
||||||
|
NOCACHE=--no-cache
|
||||||
|
|
||||||
.DEFAULT: buildx
|
.DEFAULT_GOAL := dummy
|
||||||
|
|
||||||
buildx:
|
dummy:
|
||||||
@docker build -t ${IMAGE}:${VERSION} .
|
@echo "Nothing to do here."
|
||||||
|
|
||||||
publish:
|
build:
|
||||||
@docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push .
|
docker build ${NOCACHE} -t ${IMAGE}:${VERSION} .
|
||||||
|
|
||||||
|
public:
|
||||||
|
docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push .
|
||||||
|
|
||||||
|
debug:
|
||||||
|
docker buildx build ${NOCACHE} --platform linux/amd64,linux/arm64 -t ${IMAGE}:debug --push .
|
||||||
|
|
||||||
|
test:
|
||||||
|
docker buildx build ${NOCACHE} --platform linux/arm64 -t docker.quiq.im/registry-ui:test -t docker.quiq.sh/registry-ui:test --push .
|
||||||
|
101
README.md
@ -1,40 +1,61 @@
|
|||||||
## Docker Registry UI
|
## Registry UI
|
||||||
|
|
||||||
[](https://goreportcard.com/report/github.com/quiq/docker-registry-ui)
|
[](https://goreportcard.com/report/github.com/quiq/registry-ui)
|
||||||
|
|
||||||
### Overview
|
### Overview
|
||||||
|
|
||||||
* Web UI for Docker Registry
|
* Web UI for Docker Registry or similar alternatives
|
||||||
* Browse namespaces, repositories and tags
|
* Fast, simple and small package
|
||||||
* Display image details by layers
|
* Browse catalog of repositories and tags
|
||||||
* Display sub-images of multi-arch or cache type of image
|
* Show an arbitrary level of repository tree
|
||||||
* Support Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 and their confusing combinations
|
* Support Docker and OCI image formats
|
||||||
* Fast and small, written on Go
|
* Support image and image index manifests (multi-platform images)
|
||||||
* Automatically discover an authentication method (basic auth, token service etc.)
|
* Display full information about image index and links to the underlying sub-images
|
||||||
* Caching the list of repositories, tag counts and refreshing in background
|
* Display full information about image, its layers and config file (command history)
|
||||||
* Event listener of notification events coming from Registry
|
* Event listener for notification events coming from Registry
|
||||||
* Store events in sqlite or MySQL database
|
* Store events in Sqlite or MySQL database
|
||||||
* CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags
|
* CLI option to maintain the tag retention: purge tags older than X days keeping at least Y tags etc.
|
||||||
|
* Automatically discover an authentication method: basic auth, token service, keychain etc.
|
||||||
|
* The list of repositories and tag counts are cached and refreshed in background
|
||||||
|
|
||||||
No TLS or authentication implemented on the UI web server itself.
|
No TLS or authentication is implemented on the UI instance itself.
|
||||||
Assuming you will proxy it behind nginx, oauth2_proxy or something.
|
Assuming you will put it behind nginx, oauth2_proxy or similar.
|
||||||
|
|
||||||
Docker images [quiq/docker-registry-ui](https://hub.docker.com/r/quiq/docker-registry-ui/tags/)
|
Docker images [quiq/registry-ui](https://hub.docker.com/r/quiq/registry-ui/tags/)
|
||||||
|
|
||||||
|
### Quick start
|
||||||
|
|
||||||
|
Run a Docker registry in your host (if you don't already had one):
|
||||||
|
|
||||||
|
docker run -d --network host \
|
||||||
|
--name registry registry:2
|
||||||
|
|
||||||
|
Run registry UI directly connected to it:
|
||||||
|
|
||||||
|
docker run -d --network host \
|
||||||
|
-e REGISTRY_HOSTNAME=127.0.0.1:5000 \
|
||||||
|
-e REGISTRY_INSECURE=true \
|
||||||
|
--name registry-ui quiq/registry-ui
|
||||||
|
|
||||||
|
Push any Docker image to 127.0.0.1:5000/owner/name and go into http://127.0.0.1:8000 with
|
||||||
|
your web browser.
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
The configuration is stored in `config.yml` and the options are self-descriptive.
|
The configuration is stored in `config.yml` and the options are self-descriptive.
|
||||||
|
|
||||||
### Run UI
|
You can override any config option via environment variables using SECTION_KEY_NAME syntax,
|
||||||
|
e.g. `LISTEN_ADDR`, `PERFORMANCE_TAGS_COUNT_REFRESH_INTERVAL`, `REGISTRY_HOSTNAME` etc.
|
||||||
|
|
||||||
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro \
|
Passing the full config file through:
|
||||||
--name=registry-ui quiq/docker-registry-ui
|
|
||||||
|
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro quiq/registry-ui
|
||||||
|
|
||||||
To run with your own root CA certificate, add to the command:
|
To run with your own root CA certificate, add to the command:
|
||||||
|
|
||||||
-v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
-v /local/rootcacerts.crt:/etc/ssl/certs/ca-certificates.crt:ro
|
||||||
|
|
||||||
To preserve sqlite db file with event notifications data, add to the command:
|
To preserve sqlite db file with event data, add to the command:
|
||||||
|
|
||||||
-v /local/data:/opt/data
|
-v /local/data:/opt/data
|
||||||
|
|
||||||
@ -53,8 +74,8 @@ To receive events you need to configure Registry as follow:
|
|||||||
|
|
||||||
notifications:
|
notifications:
|
||||||
endpoints:
|
endpoints:
|
||||||
- name: docker-registry-ui
|
- name: registry-ui
|
||||||
url: http://docker-registry-ui.local:8000/api/events
|
url: http://registry-ui.local:8000/event-receiver
|
||||||
headers:
|
headers:
|
||||||
Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890]
|
Authorization: [Bearer abcdefghijklmnopqrstuvwxyz1234567890]
|
||||||
timeout: 1s
|
timeout: 1s
|
||||||
@ -64,7 +85,7 @@ To receive events you need to configure Registry as follow:
|
|||||||
- application/octet-stream
|
- application/octet-stream
|
||||||
|
|
||||||
Adjust url and token as appropriate.
|
Adjust url and token as appropriate.
|
||||||
If you are running UI from non-root base path, e.g. /ui, the URL path for above will be `/ui/api/events`.
|
If you are running UI with non-default base path, e.g. /ui, the URL path for above will be `/ui/event-receiver` etc.
|
||||||
|
|
||||||
## Using MySQL instead of sqlite3 for event listener
|
## Using MySQL instead of sqlite3 for event listener
|
||||||
|
|
||||||
@ -78,7 +99,7 @@ You can create a table manually if you don't want to grant `CREATE` permission:
|
|||||||
action CHAR(4) NULL,
|
action CHAR(4) NULL,
|
||||||
repository VARCHAR(100) NULL,
|
repository VARCHAR(100) NULL,
|
||||||
tag VARCHAR(100) NULL,
|
tag VARCHAR(100) NULL,
|
||||||
ip VARCHAR(15) NULL,
|
ip VARCHAR(45) NULL,
|
||||||
user VARCHAR(50) NULL,
|
user VARCHAR(50) NULL,
|
||||||
created DATETIME NULL
|
created DATETIME NULL
|
||||||
);
|
);
|
||||||
@ -94,46 +115,26 @@ To delete tags you need to enable the corresponding option in Docker Registry co
|
|||||||
The following example shows how to run a cron task to purge tags older than X days but also keep
|
The following example shows how to run a cron task to purge tags older than X days but also keep
|
||||||
at least Y tags no matter how old. Assuming container has been already running.
|
at least Y tags no matter how old. Assuming container has been already running.
|
||||||
|
|
||||||
10 3 * * * root docker exec -t registry-ui /opt/docker-registry-ui -purge-tags
|
10 3 * * * root docker exec -t registry-ui /opt/registry-ui -purge-tags
|
||||||
|
|
||||||
You can try to run in dry-run mode first to see what is going to be purged:
|
You can try to run in dry-run mode first to see what is going to be purged:
|
||||||
|
|
||||||
docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run
|
docker exec -t registry-ui /opt/registry-ui -purge-tags -dry-run
|
||||||
|
|
||||||
Alternatively, you can schedule the purging task with built-in cron feature:
|
|
||||||
|
|
||||||
purge_tags_schedule: '0 10 3 * * *'
|
|
||||||
|
|
||||||
Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
|
|
||||||
|
|
||||||
### Debug mode
|
|
||||||
|
|
||||||
To increase http request verbosity, run container with `-e GOREQUEST_DEBUG=1`.
|
|
||||||
|
|
||||||
### About Docker image formats...
|
|
||||||
|
|
||||||
Docker image formats and their confusing combinations as supported by this UI:
|
|
||||||
|
|
||||||
* Manifest v2 schema 1 only: older format, e.g. created with Docker 1.9.
|
|
||||||
* Manifest v2 schema 1 + Manifest v2 schema 2: current format of a single image, the image history are coming from schema 1, should be referenced by repo:tag name.
|
|
||||||
* Manifest v2 schema 1 + Manifest List v2 schema 2: multi-arch image format containing digests of sub-images, the image history are coming from schema 1 (no idea from what sub-image it was picked up when created), should be referenced by repo:tag name.
|
|
||||||
* Manifest v2 schema 2: current image format referenced by its digest sha256, no image history.
|
|
||||||
* Manifest List v2 schema 2: multi-arch image referenced by its digest sha256 or cache image referenced by tag name, no image history.
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
Repository list / home page:
|
Repository list:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Repository tag list:
|
Tag list:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Tag info page:
|
Image Index info:
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
Event log page:
|
Image info:
|
||||||
|
|
||||||

|

|
||||||
|
83
config.go
@ -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 = ®istry.PurgeTagsConfig{
|
|
||||||
KeepDays: config.PurgeTagsKeepDays,
|
|
||||||
KeepMinCount: config.PurgeTagsKeepCount,
|
|
||||||
KeepTagRegexp: config.PurgeTagsKeepRegexp,
|
|
||||||
KeepFromFile: config.PurgeTagsKeepFromFile,
|
|
||||||
}
|
|
||||||
return &config
|
|
||||||
}
|
|
111
config.yml
@ -1,69 +1,86 @@
|
|||||||
# Listen interface.
|
# Listen interface.
|
||||||
listen_addr: 0.0.0.0:8000
|
listen_addr: 0.0.0.0:8000
|
||||||
# Base path of Docker Registry UI.
|
|
||||||
base_path: /
|
|
||||||
|
|
||||||
# Registry URL with schema and port.
|
# Base path of Registry UI.
|
||||||
registry_url: https://docker-registry.local
|
uri_base_path: /
|
||||||
# Verify TLS certificate when using https.
|
|
||||||
verify_tls: true
|
|
||||||
|
|
||||||
# Docker registry credentials.
|
# Background tasks.
|
||||||
|
performance:
|
||||||
|
# Catalog list page size. It depends from the underlying storage performance.
|
||||||
|
catalog_page_size: 100
|
||||||
|
|
||||||
|
# Catalog (repo list) refresh interval in minutes.
|
||||||
|
# If set to 0 it will never refresh but will run once.
|
||||||
|
catalog_refresh_interval: 10
|
||||||
|
|
||||||
|
# Tags counting refresh interval in minutes.
|
||||||
|
# If set to 0 it will never run. This is fast operation.
|
||||||
|
tags_count_refresh_interval: 60
|
||||||
|
|
||||||
|
# Registry endpoint and authentication.
|
||||||
|
registry:
|
||||||
|
# Registry hostname (without protocol but may include port).
|
||||||
|
hostname: docker-registry.local
|
||||||
|
# Allow to access non-https enabled registry.
|
||||||
|
insecure: false
|
||||||
|
|
||||||
|
# Registry credentials.
|
||||||
# They need to have a full access to the registry.
|
# They need to have a full access to the registry.
|
||||||
# If token authentication service is enabled, it will be auto-discovered and those credentials
|
# If token authentication service is enabled, it will be auto-discovered and those credentials
|
||||||
# will be used to obtain access tokens.
|
# will be used to obtain access tokens.
|
||||||
# When the registry_password_file entry is used, the password can be passed as a docker secret
|
username: user
|
||||||
# and read from file. This overides the registry_password entry.
|
password: pass
|
||||||
registry_username: user
|
# Set password to '' in order to read it from the file below. Otherwise, it is ignored.
|
||||||
registry_password: pass
|
password_file: /run/secrets/registry_password_file
|
||||||
# registry_password_file: /run/secrets/registry_password_file
|
|
||||||
|
|
||||||
# Event listener token.
|
# Alternatively, you can do auth with Keychain, useful for local development.
|
||||||
# The same one should be configured on Docker registry as Authorization Bearer token.
|
# When enabled the above credentials will not be used.
|
||||||
event_listener_token: token
|
auth_with_keychain: false
|
||||||
# Retention of records to keep.
|
|
||||||
event_retention_days: 7
|
|
||||||
|
|
||||||
# Event listener storage.
|
# UI access management.
|
||||||
event_database_driver: sqlite3
|
access_control:
|
||||||
event_database_location: data/registry_events.db
|
# Whether users can the event log. Otherwise, only admins listed below.
|
||||||
# event_database_driver: mysql
|
|
||||||
# event_database_location: user:password@tcp(localhost:3306)/docker_events
|
|
||||||
|
|
||||||
# You can disable event deletion on some hosts when you are running docker-registry on master-master or
|
|
||||||
# cluster setup to avoid deadlocks or replication break.
|
|
||||||
event_deletion_enabled: true
|
|
||||||
|
|
||||||
# Cache refresh interval in minutes.
|
|
||||||
# How long to cache repository list and tag counts.
|
|
||||||
cache_refresh_interval: 10
|
|
||||||
|
|
||||||
# If all users can view the event log. If set to false, then only admins listed below.
|
|
||||||
anyone_can_view_events: true
|
anyone_can_view_events: true
|
||||||
# If all users can delete tags. If set to false, then only admins listed below.
|
# Whether users can delete tags. Otherwise, only admins listed below.
|
||||||
anyone_can_delete: false
|
anyone_can_delete_tags: false
|
||||||
# Users allowed to delete tags.
|
# The list of users to do everything.
|
||||||
# This should be sent via X-WEBAUTH-USER header from your proxy.
|
# User identifier should be set via X-WEBAUTH-USER header from your proxy
|
||||||
|
# because registry UI itself does not employ any auth.
|
||||||
admins: []
|
admins: []
|
||||||
|
|
||||||
# Debug mode. Affects only templates.
|
# Event listener configuration.
|
||||||
debug: true
|
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
|
||||||
|
|
||||||
|
# Event listener storage.
|
||||||
|
database_driver: sqlite3
|
||||||
|
database_location: data/registry_events.db
|
||||||
|
# database_driver: mysql
|
||||||
|
# database_location: user:password@tcp(localhost:3306)/docker_events
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Options for tag purging.
|
||||||
|
purge_tags:
|
||||||
# How many days to keep tags but also keep the minimal count provided no matter how old.
|
# How many days to keep tags but also keep the minimal count provided no matter how old.
|
||||||
purge_tags_keep_days: 90
|
keep_days: 90
|
||||||
purge_tags_keep_count: 2
|
keep_count: 10
|
||||||
|
|
||||||
# Keep tags matching regexp no matter how old, e.g. '^latest$'
|
# Keep tags matching regexp no matter how old, e.g. '^latest$'
|
||||||
# Empty string disables this feature.
|
# Empty string disables this feature.
|
||||||
purge_tags_keep_regexp: ''
|
keep_regexp: ''
|
||||||
|
|
||||||
# Keep tags listed in the file no matter how old.
|
# Keep tags listed in the file no matter how old.
|
||||||
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
|
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
|
||||||
# Empty string disables this feature.
|
# Empty string disables this feature.
|
||||||
purge_tags_keep_from_file: ''
|
keep_from_file: ''
|
||||||
|
|
||||||
# Enable built-in cron to schedule purging tags in server mode.
|
# Debug mode.
|
||||||
# Empty string disables this feature.
|
debug:
|
||||||
# Example: '25 54 17 * * *' will run it at 17:54:25 daily.
|
# Affects only templates.
|
||||||
# Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
|
templates: false
|
||||||
purge_tags_schedule: ''
|
|
||||||
|
@ -4,12 +4,14 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/quiq/docker-registry-ui/registry"
|
"github.com/quiq/registry-ui/registry"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
// 🐒 patching of "database/sql".
|
// 🐒 patching of "database/sql".
|
||||||
_ "github.com/go-sql-driver/mysql"
|
_ "github.com/go-sql-driver/mysql"
|
||||||
@ -18,13 +20,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
userAgent = "registry-ui"
|
||||||
schemaSQLite = `
|
schemaSQLite = `
|
||||||
CREATE TABLE events (
|
CREATE TABLE events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
action CHAR(5) NULL,
|
action CHAR(5) NULL,
|
||||||
repository VARCHAR(100) NULL,
|
repository VARCHAR(100) NULL,
|
||||||
tag VARCHAR(100) NULL,
|
tag VARCHAR(100) NULL,
|
||||||
ip VARCHAR(15) NULL,
|
ip VARCHAR(45) NULL,
|
||||||
user VARCHAR(50) NULL,
|
user VARCHAR(50) NULL,
|
||||||
created DATETIME NULL
|
created DATETIME NULL
|
||||||
);
|
);
|
||||||
@ -56,7 +59,16 @@ type EventRow struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewEventListener initialize EventListener.
|
// NewEventListener initialize EventListener.
|
||||||
func NewEventListener(databaseDriver, databaseLocation string, retention int, eventDeletion bool) *EventListener {
|
func NewEventListener() *EventListener {
|
||||||
|
databaseDriver := viper.GetString("event_listener.database_driver")
|
||||||
|
databaseLocation := viper.GetString("event_listener.database_location")
|
||||||
|
retention := viper.GetInt("event_listener.retention_days")
|
||||||
|
eventDeletion := viper.GetBool("event_listener.deletion_enabled")
|
||||||
|
|
||||||
|
if databaseDriver != "sqlite3" && databaseDriver != "mysql" {
|
||||||
|
panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql"))
|
||||||
|
}
|
||||||
|
|
||||||
return &EventListener{
|
return &EventListener{
|
||||||
databaseDriver: databaseDriver,
|
databaseDriver: databaseDriver,
|
||||||
databaseLocation: databaseLocation,
|
databaseLocation: databaseLocation,
|
||||||
@ -90,8 +102,8 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
|
|||||||
}
|
}
|
||||||
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
|
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
|
||||||
for _, i := range gjson.GetBytes(j, "events").Array() {
|
for _, i := range gjson.GetBytes(j, "events").Array() {
|
||||||
// Ignore calls by docker-registry-ui itself.
|
// Ignore calls by registry-ui itself.
|
||||||
if i.Get("request.useragent").String() == "docker-registry-ui" {
|
if strings.HasPrefix(i.Get("request.useragent").String(), userAgent) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
action := i.Get("action").String()
|
action := i.Get("action").String()
|
||||||
@ -101,7 +113,10 @@ func (e *EventListener) ProcessEvents(request *http.Request) {
|
|||||||
if tag == "" {
|
if tag == "" {
|
||||||
tag = i.Get("target.digest").String()
|
tag = i.Get("target.digest").String()
|
||||||
}
|
}
|
||||||
ip := strings.Split(i.Get("request.addr").String(), ":")[0]
|
ip := i.Get("request.addr").String()
|
||||||
|
if x, _, _ := net.SplitHostPort(ip); x != "" {
|
||||||
|
ip = x
|
||||||
|
}
|
||||||
user := i.Get("actor.name").String()
|
user := i.Get("actor.name").String()
|
||||||
e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
|
e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
|
||||||
|
|
||||||
@ -143,7 +158,8 @@ func (e *EventListener) GetEvents(repository string) []EventRow {
|
|||||||
|
|
||||||
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
|
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
|
||||||
if repository != "" {
|
if repository != "" {
|
||||||
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository)
|
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' OR repository LIKE '%s/%%' ORDER BY id DESC LIMIT 5",
|
||||||
|
repository, repository)
|
||||||
}
|
}
|
||||||
rows, err := db.Query(query)
|
rows, err := db.Query(query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
66
go.mod
@ -1,39 +1,61 @@
|
|||||||
module github.com/quiq/docker-registry-ui
|
module github.com/quiq/registry-ui
|
||||||
|
|
||||||
|
go 1.24.0
|
||||||
|
|
||||||
|
toolchain go1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible
|
github.com/CloudyKit/jet/v6 v6.3.1
|
||||||
github.com/go-sql-driver/mysql v1.7.1
|
github.com/fatih/color v1.18.0
|
||||||
github.com/labstack/echo/v4 v4.11.4
|
github.com/go-sql-driver/mysql v1.9.2
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/google/go-containerregistry v0.20.5
|
||||||
github.com/parnurzeal/gorequest v0.2.16
|
github.com/labstack/echo/v4 v4.13.4
|
||||||
github.com/robfig/cron v1.2.0
|
github.com/mattn/go-sqlite3 v1.14.28
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
github.com/smartystreets/goconvey v1.8.1
|
github.com/smartystreets/goconvey v1.8.1
|
||||||
github.com/tidwall/gjson v1.17.1
|
github.com/spf13/viper v1.20.1
|
||||||
gopkg.in/yaml.v2 v2.4.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
|
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
|
||||||
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 // indirect
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
github.com/docker/cli v28.2.2+incompatible // indirect
|
||||||
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
|
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
github.com/gopherjs/gopherjs v1.17.2 // indirect
|
||||||
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
github.com/jtolds/gls v4.20.0+incompatible // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/smarty/assertions v1.15.0 // indirect
|
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||||
|
github.com/smarty/assertions v1.16.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
|
github.com/spf13/cast v1.9.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.0 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||||
golang.org/x/crypto v0.17.0 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
golang.org/x/net v0.19.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/sys v0.15.0 // indirect
|
golang.org/x/crypto v0.39.0 // indirect
|
||||||
golang.org/x/text v0.14.0 // indirect
|
golang.org/x/net v0.41.0 // indirect
|
||||||
golang.org/x/time v0.5.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
moul.io/http2curl v1.0.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
|
golang.org/x/text v0.26.0 // indirect
|
||||||
|
golang.org/x/time v0.12.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
go 1.19
|
|
||||||
|
134
go.sum
@ -1,78 +1,126 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c=
|
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c=
|
||||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible h1:rZgFj+Gtf3NMi/U5FvCvhzaxzW/TaPYgUYx3bAPz9DE=
|
github.com/CloudyKit/jet/v6 v6.3.1 h1:6IAo5Cx21xrHVaR8zzXN5gJatKV/wO7Nf6bfCnCSbUw=
|
||||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
|
github.com/CloudyKit/jet/v6 v6.3.1/go.mod h1:lf8ksdNsxZt7/yH/3n4vJQWA9RUq4wpaHtArHhGVMOw=
|
||||||
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||||
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5 h1:m62nsMU279qRD9PQSWD1l66kmkXzuYcnVJqL4XLeV2M=
|
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
|
||||||
github.com/elazarl/goproxy v0.0.0-20231117061959-7cc037d33fb5/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
|
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
|
||||||
|
github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/go-containerregistry v0.20.5 h1:4RnlYcDs5hoA++CeFjlbZ/U9Yp1EuWr+UhhTyYQjOP0=
|
||||||
|
github.com/google/go-containerregistry v0.20.5/go.mod h1:Q14vdOOzug02bwnhMkZKD4e30pDaD9W65qzXpyzF49E=
|
||||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/labstack/echo/v4 v4.11.4 h1:vDZmA+qNeh1pd/cCkEicDMrjtrnMGQ1QFI9gWN1zGq8=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/labstack/echo/v4 v4.11.4/go.mod h1:noh7EvLwqDsmh/X/HWKPUl1AjzJrhyptRyEbQJfxen8=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
|
||||||
|
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
|
||||||
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
|
||||||
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
|
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||||
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
|
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
|
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
|
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
|
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY=
|
github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY=
|
||||||
github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec=
|
github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI=
|
||||||
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY=
|
||||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
|
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||||
|
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
|
||||||
|
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
|
||||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
|
||||||
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||||
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||||
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||||
|
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||||
|
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||||
|
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||||
|
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
|
||||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
|
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||||
|
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||||
|
97
main.go
@ -3,20 +3,20 @@ package main
|
|||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/labstack/echo/v4/middleware"
|
"github.com/labstack/echo/v4/middleware"
|
||||||
"github.com/quiq/docker-registry-ui/events"
|
"github.com/quiq/registry-ui/events"
|
||||||
"github.com/quiq/docker-registry-ui/registry"
|
"github.com/quiq/registry-ui/registry"
|
||||||
"github.com/robfig/cron"
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
type apiClient struct {
|
type apiClient struct {
|
||||||
client *registry.Client
|
client *registry.Client
|
||||||
eventListener *events.EventListener
|
eventListener *events.EventListener
|
||||||
config *configData
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@ -25,11 +25,15 @@ func main() {
|
|||||||
|
|
||||||
configFile, loggingLevel string
|
configFile, loggingLevel string
|
||||||
purgeTags, purgeDryRun bool
|
purgeTags, purgeDryRun bool
|
||||||
|
purgeIncludeRepos, purgeExcludeRepos string
|
||||||
)
|
)
|
||||||
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
|
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
|
||||||
flag.StringVar(&loggingLevel, "log-level", "info", "logging level")
|
flag.StringVar(&loggingLevel, "log-level", "info", "logging level")
|
||||||
|
|
||||||
flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server")
|
flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server")
|
||||||
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
|
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
|
||||||
|
flag.StringVar(&purgeIncludeRepos, "purge-include-repos", "", "comma-separated list of repos to purge tags from, otherwise all")
|
||||||
|
flag.StringVar(&purgeExcludeRepos, "purge-exclude-repos", "", "comma-separated list of repos to skip from purging tags, otherwise none")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// Setup logging
|
// Setup logging
|
||||||
@ -40,70 +44,63 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read config file
|
// Read config file
|
||||||
a.config = readConfig(configFile)
|
viper.SetConfigName(strings.Split(filepath.Base(configFile), ".")[0])
|
||||||
a.config.PurgeConfig.DryRun = purgeDryRun
|
viper.AddConfigPath(filepath.Dir(configFile))
|
||||||
|
viper.AddConfigPath(".")
|
||||||
|
err := viper.ReadInConfig()
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("fatal error reading config file: %w", err))
|
||||||
|
}
|
||||||
|
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
viper.AutomaticEnv()
|
||||||
|
|
||||||
// Init registry API client.
|
// Init registry API client.
|
||||||
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
|
a.client = registry.NewClient()
|
||||||
if a.client == nil {
|
|
||||||
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
|
|
||||||
}
|
|
||||||
|
|
||||||
purgeFunc := func() {
|
|
||||||
registry.PurgeOldTags(a.client, a.config.PurgeConfig)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute CLI task and exit.
|
// Execute CLI task and exit.
|
||||||
if purgeTags {
|
if purgeTags {
|
||||||
purgeFunc()
|
registry.PurgeOldTags(a.client, purgeDryRun, purgeIncludeRepos, purgeExcludeRepos)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Schedules to purge tags.
|
go a.client.StartBackgroundJobs()
|
||||||
if a.config.PurgeTagsSchedule != "" {
|
a.eventListener = events.NewEventListener()
|
||||||
c := cron.New()
|
|
||||||
if err := c.AddFunc(a.config.PurgeTagsSchedule, purgeFunc); err != nil {
|
|
||||||
panic(fmt.Errorf("invalid schedule format: %s", a.config.PurgeTagsSchedule))
|
|
||||||
}
|
|
||||||
c.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count tags in background.
|
|
||||||
go a.client.CountTags(a.config.CacheRefreshInterval)
|
|
||||||
|
|
||||||
if a.config.EventDatabaseDriver != "sqlite3" && a.config.EventDatabaseDriver != "mysql" {
|
|
||||||
panic(fmt.Errorf("event_database_driver should be either sqlite3 or mysql"))
|
|
||||||
}
|
|
||||||
a.eventListener = events.NewEventListener(
|
|
||||||
a.config.EventDatabaseDriver, a.config.EventDatabaseLocation, a.config.EventRetentionDays, a.config.EventDeletionEnabled,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Template engine init.
|
// Template engine init.
|
||||||
e := echo.New()
|
e := echo.New()
|
||||||
registryHost, _ := url.Parse(a.config.RegistryURL) // validated already in config.go
|
// e.Use(middleware.Logger())
|
||||||
e.Renderer = setupRenderer(a.config.Debug, registryHost.Host, a.config.BasePath)
|
e.Use(loggingMiddleware())
|
||||||
|
e.Use(recoverMiddleware())
|
||||||
|
|
||||||
|
basePath := viper.GetString("uri_base_path")
|
||||||
|
// Normalize base path.
|
||||||
|
basePath = strings.Trim(basePath, "/")
|
||||||
|
if basePath != "" {
|
||||||
|
basePath = "/" + basePath
|
||||||
|
}
|
||||||
|
e.Renderer = setupRenderer(basePath)
|
||||||
|
|
||||||
// Web routes.
|
// Web routes.
|
||||||
e.File("/favicon.ico", "static/favicon.ico")
|
e.File("/favicon.ico", "static/favicon.ico")
|
||||||
e.Static(a.config.BasePath+"/static", "static")
|
e.Static(basePath+"/static", "static")
|
||||||
if a.config.BasePath != "" {
|
|
||||||
e.GET(a.config.BasePath, a.viewRepositories)
|
p := e.Group(basePath)
|
||||||
|
if basePath != "" {
|
||||||
|
e.GET(basePath, a.viewCatalog)
|
||||||
}
|
}
|
||||||
e.GET(a.config.BasePath+"/", a.viewRepositories)
|
p.GET("/", a.viewCatalog)
|
||||||
e.GET(a.config.BasePath+"/:namespace", a.viewRepositories)
|
p.GET("/:repoPath", a.viewCatalog)
|
||||||
e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags)
|
p.GET("/event-log", a.viewEventLog)
|
||||||
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo)
|
p.GET("/delete-tag", a.deleteTag)
|
||||||
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
|
|
||||||
e.GET(a.config.BasePath+"/events", a.viewLog)
|
|
||||||
|
|
||||||
// Protected event listener.
|
// Protected event listener.
|
||||||
p := e.Group(a.config.BasePath + "/api")
|
pp := e.Group("/event-receiver")
|
||||||
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
|
pp.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
|
||||||
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
|
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
|
||||||
return token == a.config.EventListenerToken, nil
|
return token == viper.GetString("event_listener.bearer_token"), nil
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
p.POST("/events", a.receiveEvents)
|
pp.POST("", a.receiveEvents)
|
||||||
|
|
||||||
e.Logger.Fatal(e.Start(a.config.ListenAddr))
|
e.Logger.Fatal(e.Start(viper.GetString("listen_addr")))
|
||||||
}
|
}
|
||||||
|
84
middleware.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,324 +1,363 @@
|
|||||||
package registry
|
package registry
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto"
|
"context"
|
||||||
"crypto/tls"
|
"encoding/json"
|
||||||
"fmt"
|
"os"
|
||||||
"regexp"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/parnurzeal/gorequest"
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||||
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const userAgent = "docker-registry-ui"
|
const userAgent = "registry-ui"
|
||||||
|
|
||||||
// Client main class.
|
// Client main class.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
url string
|
puller *remote.Puller
|
||||||
verifyTLS bool
|
pusher *remote.Pusher
|
||||||
username string
|
|
||||||
password string
|
|
||||||
request *gorequest.SuperAgent
|
|
||||||
logger *logrus.Entry
|
logger *logrus.Entry
|
||||||
mux sync.Mutex
|
repos []string
|
||||||
tokens map[string]string
|
tagCountsMux sync.Mutex
|
||||||
repos map[string][]string
|
|
||||||
tagCounts map[string]int
|
tagCounts map[string]int
|
||||||
authURL string
|
isCatalogReady bool
|
||||||
|
nameOptions []name.Option
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageInfo struct {
|
||||||
|
IsImageIndex bool
|
||||||
|
IsImage bool
|
||||||
|
ImageRefRepo string
|
||||||
|
ImageRefTag string
|
||||||
|
ImageRefDigest string
|
||||||
|
MediaType string
|
||||||
|
Platforms string
|
||||||
|
Manifest map[string]interface{}
|
||||||
|
|
||||||
|
// Image specific
|
||||||
|
ImageSize int64
|
||||||
|
Created time.Time
|
||||||
|
ConfigImageID string
|
||||||
|
ConfigFile map[string]interface{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient initialize Client.
|
// NewClient initialize Client.
|
||||||
func NewClient(url string, verifyTLS bool, username, password string) *Client {
|
func NewClient() *Client {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
insecure := viper.GetBool("registry.insecure")
|
||||||
|
nameOptions := []name.Option{}
|
||||||
|
if insecure {
|
||||||
|
nameOptions = append(nameOptions, name.Insecure)
|
||||||
|
}
|
||||||
|
|
||||||
c := &Client{
|
c := &Client{
|
||||||
url: strings.TrimRight(url, "/"),
|
puller: puller,
|
||||||
verifyTLS: verifyTLS,
|
pusher: pusher,
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
|
|
||||||
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
|
|
||||||
logger: SetupLogging("registry.client"),
|
logger: SetupLogging("registry.client"),
|
||||||
tokens: map[string]string{},
|
repos: []string{},
|
||||||
repos: map[string][]string{},
|
|
||||||
tagCounts: map[string]int{},
|
tagCounts: map[string]int{},
|
||||||
|
nameOptions: nameOptions,
|
||||||
}
|
}
|
||||||
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
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// getToken get existing or new auth token.
|
func (c *Client) StartBackgroundJobs() {
|
||||||
func (c *Client) getToken(scope string) string {
|
catalogInterval := viper.GetInt("performance.catalog_refresh_interval")
|
||||||
// Check if we have already a token and it's not expired.
|
tagsCountInterval := viper.GetInt("performance.tags_count_refresh_interval")
|
||||||
if token, ok := c.tokens[scope]; ok {
|
isStarted := false
|
||||||
resp, _, _ := c.request.Get(c.url+"/v2/").
|
|
||||||
Set("Authorization", fmt.Sprintf("Bearer %s", token)).
|
|
||||||
Set("User-Agent", userAgent).End()
|
|
||||||
if resp != nil && resp.StatusCode == 200 {
|
|
||||||
return token
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !c.verifyTLS})
|
|
||||||
resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).
|
|
||||||
SetBasicAuth(c.username, c.password).
|
|
||||||
Set("User-Agent", userAgent).End()
|
|
||||||
if len(errs) > 0 {
|
|
||||||
c.logger.Error(errs[0])
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
c.logger.Error("Failed to get token for scope ", scope, " from ", c.authURL)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
token := gjson.Get(data, "token").String()
|
|
||||||
// Fix for docker_auth v1.5.0 only
|
|
||||||
if token == "" {
|
|
||||||
token = gjson.Get(data, "access_token").String()
|
|
||||||
}
|
|
||||||
|
|
||||||
c.tokens[scope] = token
|
|
||||||
c.logger.Debugf("Received new token for scope %s", scope)
|
|
||||||
|
|
||||||
return c.tokens[scope]
|
|
||||||
}
|
|
||||||
|
|
||||||
// callRegistry make an HTTP request to retrieve data from Docker registry.
|
|
||||||
func (c *Client) callRegistry(uri, scope, manifestFormat string) (string, gorequest.Response) {
|
|
||||||
acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.%s+json", manifestFormat)
|
|
||||||
authHeader := ""
|
|
||||||
if c.authURL != "" {
|
|
||||||
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, data, errs := c.request.Get(c.url+uri).
|
|
||||||
Set("Accept", acceptHeader).
|
|
||||||
Set("Authorization", authHeader).
|
|
||||||
Set("User-Agent", userAgent).End()
|
|
||||||
if len(errs) > 0 {
|
|
||||||
c.logger.Error(errs[0])
|
|
||||||
return "", resp
|
|
||||||
}
|
|
||||||
|
|
||||||
c.logger.Debugf("GET %s %s", uri, resp.Status)
|
|
||||||
// Returns 404 when no tags in the repo.
|
|
||||||
if resp.StatusCode != 200 {
|
|
||||||
return "", resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure Docker-Content-Digest header is present as we use it in various places.
|
|
||||||
// The header is probably in AWS ECR case.
|
|
||||||
digest := resp.Header.Get("Docker-Content-Digest")
|
|
||||||
if digest == "" {
|
|
||||||
// Try to get digest from body instead, should be equal to what would be presented in Docker-Content-Digest.
|
|
||||||
h := crypto.SHA256.New()
|
|
||||||
h.Write([]byte(data))
|
|
||||||
resp.Header.Set("Docker-Content-Digest", fmt.Sprintf("sha256:%x", h.Sum(nil)))
|
|
||||||
}
|
|
||||||
return data, resp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Namespaces list repo namespaces.
|
|
||||||
func (c *Client) Namespaces() []string {
|
|
||||||
namespaces := make([]string, 0, len(c.repos))
|
|
||||||
for k := range c.repos {
|
|
||||||
namespaces = append(namespaces, k)
|
|
||||||
}
|
|
||||||
if !ItemInSlice("library", namespaces) {
|
|
||||||
namespaces = append(namespaces, "library")
|
|
||||||
}
|
|
||||||
sort.Strings(namespaces)
|
|
||||||
return namespaces
|
|
||||||
}
|
|
||||||
|
|
||||||
// Repositories list repos by namespaces where 'library' is the default one.
|
|
||||||
func (c *Client) Repositories(useCache bool) map[string][]string {
|
|
||||||
// Return from cache if available.
|
|
||||||
if len(c.repos) > 0 && useCache {
|
|
||||||
return c.repos
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mux.Lock()
|
|
||||||
defer c.mux.Unlock()
|
|
||||||
|
|
||||||
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
|
|
||||||
scope := "registry:catalog:*"
|
|
||||||
uri := "/v2/_catalog"
|
|
||||||
tmp := map[string][]string{}
|
|
||||||
for {
|
for {
|
||||||
data, resp := c.callRegistry(uri, scope, "manifest.v2")
|
c.RefreshCatalog()
|
||||||
if data == "" {
|
if !isStarted && tagsCountInterval > 0 {
|
||||||
c.repos = tmp
|
// Start after the first catalog refresh
|
||||||
return c.repos
|
go c.CountTags(tagsCountInterval)
|
||||||
|
isStarted = true
|
||||||
}
|
}
|
||||||
|
if catalogInterval == 0 {
|
||||||
for _, r := range gjson.Get(data, "repositories").Array() {
|
c.logger.Warn("Catalog refresh is disabled in the config and will not run anymore.")
|
||||||
namespace := "library"
|
|
||||||
repo := r.String()
|
|
||||||
if strings.Contains(repo, "/") {
|
|
||||||
f := strings.SplitN(repo, "/", 2)
|
|
||||||
namespace = f[0]
|
|
||||||
repo = f[1]
|
|
||||||
}
|
|
||||||
tmp[namespace] = append(tmp[namespace], repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// pagination
|
|
||||||
linkHeader := resp.Header.Get("Link")
|
|
||||||
link := linkRegexp.FindStringSubmatch(linkHeader)
|
|
||||||
if len(link) == 2 {
|
|
||||||
// update uri and query next page
|
|
||||||
uri = link[1]
|
|
||||||
} else {
|
|
||||||
// no more pages
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
time.Sleep(time.Duration(catalogInterval) * time.Minute)
|
||||||
}
|
}
|
||||||
c.repos = tmp
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) RefreshCatalog() {
|
||||||
|
ctx := context.Background()
|
||||||
|
start := time.Now()
|
||||||
|
c.logger.Info("[RefreshCatalog] Started reading catalog...")
|
||||||
|
registry, _ := name.NewRegistry(viper.GetString("registry.hostname"), c.nameOptions...)
|
||||||
|
cat, err := c.puller.Catalogger(ctx, registry)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("[RefreshCatalog] Error fetching catalog: %s", err)
|
||||||
|
if !c.isCatalogReady {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repos := []string{}
|
||||||
|
// The library itself does retries under the hood.
|
||||||
|
for cat.HasNext() {
|
||||||
|
data, err := cat.Next(ctx)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("[RefreshCatalog] Error listing catalog: %s", err)
|
||||||
|
}
|
||||||
|
if data != nil {
|
||||||
|
repos = append(repos, data.Repos...)
|
||||||
|
if !c.isCatalogReady {
|
||||||
|
c.repos = append(c.repos, data.Repos...)
|
||||||
|
c.logger.Debug("[RefreshCatalog] Repo batch received:", data.Repos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(repos) > 0 {
|
||||||
|
c.repos = repos
|
||||||
|
} else {
|
||||||
|
c.logger.Warn("[RefreshCatalog] Catalog looks empty, preserving previous list if any.")
|
||||||
|
}
|
||||||
|
c.logger.Debugf("[RefreshCatalog] Catalog: %s", c.repos)
|
||||||
|
c.logger.Infof("[RefreshCatalog] Job complete (%v): %d repos found", time.Since(start), len(c.repos))
|
||||||
|
c.isCatalogReady = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCatalogReady whether catalog is ready for the first time use
|
||||||
|
func (c *Client) IsCatalogReady() bool {
|
||||||
|
return c.isCatalogReady
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepos get all repos
|
||||||
|
func (c *Client) GetRepos() []string {
|
||||||
return c.repos
|
return c.repos
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags get tags for the repo.
|
// ListTags get tags for the repo
|
||||||
func (c *Client) Tags(repo string) []string {
|
func (c *Client) ListTags(repoName string) []string {
|
||||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
ctx := context.Background()
|
||||||
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, "manifest.v2")
|
repo, _ := name.NewRepository(viper.GetString("registry.hostname")+"/"+repoName, c.nameOptions...)
|
||||||
var tags []string
|
tags, err := c.puller.List(ctx, repo)
|
||||||
for _, t := range gjson.Get(data, "tags").Array() {
|
if err != nil {
|
||||||
tags = append(tags, t.String())
|
c.logger.Errorf("Error listing tags for repo %s: %s", repoName, err)
|
||||||
}
|
}
|
||||||
|
c.tagCountsMux.Lock()
|
||||||
|
c.tagCounts[repoName] = len(tags)
|
||||||
|
c.tagCountsMux.Unlock()
|
||||||
return tags
|
return tags
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManifestList gets manifest list entries for a tag for the repo.
|
// GetImageInfo get image info by the reference - tag name or digest sha256.
|
||||||
func (c *Client) ManifestList(repo, tag string) (string, []gjson.Result) {
|
func (c *Client) GetImageInfo(imageRef string) (ImageInfo, error) {
|
||||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
ctx := context.Background()
|
||||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
|
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
|
||||||
// If manifest.list.v2 does not exist because it's a normal image,
|
if err != nil {
|
||||||
// the registry returns manifest.v1 or manifest.v2 if requested by sha256.
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
||||||
info, resp := c.callRegistry(uri, scope, "manifest.list.v2")
|
return ImageInfo{}, err
|
||||||
digest := resp.Header.Get("Docker-Content-Digest")
|
|
||||||
sha256 := ""
|
|
||||||
if digest != "" {
|
|
||||||
sha256 = digest[7:]
|
|
||||||
}
|
}
|
||||||
c.logger.Debugf(`Received manifest.list.v2 with sha256 "%s" from %s: %s`, sha256, uri, info)
|
descr, err := c.puller.Get(ctx, ref)
|
||||||
return sha256, gjson.Get(info, "manifests").Array()
|
if err != nil {
|
||||||
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
||||||
|
return ImageInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagInfo get image info for the repo tag or digest sha256.
|
ii := ImageInfo{
|
||||||
func (c *Client) TagInfo(repo, tag string, v1only bool) (string, string, string) {
|
ImageRefRepo: ref.Context().RepositoryStr(),
|
||||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
ImageRefTag: ref.Identifier(),
|
||||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
|
ImageRefDigest: descr.Digest.String(),
|
||||||
// Note, if manifest.v1 does not exist because the image is requested by sha256,
|
MediaType: string(descr.MediaType),
|
||||||
// the registry returns manifest.v2 instead or manifest.list.v2 if it's the manifest list!
|
}
|
||||||
infoV1, _ := c.callRegistry(uri, scope, "manifest.v1")
|
if descr.MediaType.IsIndex() {
|
||||||
c.logger.Debugf("Received manifest.v1 from %s: %s", uri, infoV1)
|
ii.IsImageIndex = true
|
||||||
if infoV1 == "" || v1only {
|
} else if descr.MediaType.IsImage() {
|
||||||
return "", infoV1, ""
|
ii.IsImage = true
|
||||||
|
} else {
|
||||||
|
c.logger.Errorf("Image reference %s is neither Index nor Image", imageRef)
|
||||||
|
return ImageInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note, if manifest.v2 does not exist because the image is in the older format (Docker 1.9),
|
if ii.IsImage {
|
||||||
// the registry returns manifest.v1 instead or manifest.list.v2 if it's the manifest list requested by sha256!
|
img, err := descr.Image()
|
||||||
infoV2, resp := c.callRegistry(uri, scope, "manifest.v2")
|
if err != nil {
|
||||||
c.logger.Debugf("Received manifest.v2 from %s: %s", uri, infoV2)
|
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
|
||||||
digest := resp.Header.Get("Docker-Content-Digest")
|
return ImageInfo{}, err
|
||||||
if infoV2 == "" || digest == "" {
|
}
|
||||||
return "", "", ""
|
cfg, err := img.ConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
|
||||||
|
return ImageInfo{}, err
|
||||||
|
}
|
||||||
|
ii.Created = cfg.Created.Time
|
||||||
|
ii.Platforms = getPlatform(cfg.Platform())
|
||||||
|
ii.ConfigFile = structToMap(cfg)
|
||||||
|
// ImageID is what is shown in the terminal when doing "docker images".
|
||||||
|
// This is a config sha256 of the corresponding image manifest (single platform).
|
||||||
|
if x, _ := img.ConfigName(); len(x.String()) > 19 {
|
||||||
|
ii.ConfigImageID = x.String()[7:19]
|
||||||
|
}
|
||||||
|
mf, _ := img.Manifest()
|
||||||
|
for _, l := range mf.Layers {
|
||||||
|
ii.ImageSize += l.Size
|
||||||
|
}
|
||||||
|
ii.Manifest = structToMap(mf)
|
||||||
|
} else if ii.IsImageIndex {
|
||||||
|
// In case of Image Index, if we request for Image() > ConfigFile(), it will be resolved
|
||||||
|
// to a config of one of the manifests (one of the platforms).
|
||||||
|
// It doesn't make a lot of sense, even they are usually identical. Also extra API calls which slows things down.
|
||||||
|
imgIdx, err := descr.ImageIndex()
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Cannot convert descriptor to ImageIndex for image reference %s: %s", imageRef, err)
|
||||||
|
return ImageInfo{}, err
|
||||||
|
}
|
||||||
|
IdxMf, _ := imgIdx.IndexManifest()
|
||||||
|
platforms := []string{}
|
||||||
|
for _, m := range IdxMf.Manifests {
|
||||||
|
platforms = append(platforms, getPlatform(m.Platform))
|
||||||
|
}
|
||||||
|
ii.Platforms = strings.Join(UniqueSortedSlice(platforms), ", ")
|
||||||
|
ii.Manifest = structToMap(IdxMf)
|
||||||
}
|
}
|
||||||
|
|
||||||
sha256 := digest[7:]
|
return ii, nil
|
||||||
c.logger.Debugf("sha256 for %s/%s is %s", repo, tag, sha256)
|
|
||||||
return sha256, infoV1, infoV2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TagCounts return map with tag counts.
|
func getPlatform(p *v1.Platform) string {
|
||||||
func (c *Client) TagCounts() map[string]int {
|
if p != nil {
|
||||||
return c.tagCounts
|
return p.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, c.nameOptions...)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
||||||
|
return *zeroTime
|
||||||
|
}
|
||||||
|
descr, err := c.puller.Get(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
||||||
|
return *zeroTime
|
||||||
|
}
|
||||||
|
// In case of ImageIndex, it is resolved to a random sub-image which should be fine.
|
||||||
|
img, err := descr.Image()
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Cannot convert descriptor to Image for image reference %s: %s", imageRef, err)
|
||||||
|
return *zeroTime
|
||||||
|
}
|
||||||
|
cfg, err := img.ConfigFile()
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Cannot fetch ConfigFile for image reference %s: %s", imageRef, err)
|
||||||
|
return *zeroTime
|
||||||
|
}
|
||||||
|
return cfg.Created.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubRepoTagCounts return map with tag counts according to the provided list of repos/sub-repos etc.
|
||||||
|
func (c *Client) SubRepoTagCounts(repoPath string, repos []string) map[string]int {
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, r := range repos {
|
||||||
|
subRepo := r
|
||||||
|
if repoPath != "" {
|
||||||
|
subRepo = repoPath + "/" + r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire lock to prevent concurrent map iteration and map write.
|
||||||
|
c.tagCountsMux.Lock()
|
||||||
|
for k, v := range c.tagCounts {
|
||||||
|
if k == subRepo || strings.HasPrefix(k, subRepo+"/") {
|
||||||
|
counts[subRepo] = counts[subRepo] + v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.tagCountsMux.Unlock()
|
||||||
|
}
|
||||||
|
return counts
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountTags count repository tags in background regularly.
|
// CountTags count repository tags in background regularly.
|
||||||
func (c *Client) CountTags(interval uint8) {
|
func (c *Client) CountTags(interval int) {
|
||||||
for {
|
for {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
c.logger.Info("[CountTags] Calculating image tags...")
|
c.logger.Info("[CountTags] Started counting tags...")
|
||||||
catalog := c.Repositories(false)
|
for _, r := range c.repos {
|
||||||
for n, repos := range catalog {
|
c.ListTags(r)
|
||||||
for _, r := range repos {
|
|
||||||
repoPath := r
|
|
||||||
if n != "library" {
|
|
||||||
repoPath = fmt.Sprintf("%s/%s", n, r)
|
|
||||||
}
|
}
|
||||||
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
|
c.logger.Infof("[CountTags] Job complete (%v).", time.Since(start))
|
||||||
}
|
|
||||||
}
|
|
||||||
c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start))
|
|
||||||
time.Sleep(time.Duration(interval) * time.Minute)
|
time.Sleep(time.Duration(interval) * time.Minute)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteTag delete image tag.
|
// DeleteTag delete image tag.
|
||||||
func (c *Client) DeleteTag(repo, tag string) {
|
func (c *Client) DeleteTag(repoPath, tag string) {
|
||||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
ctx := context.Background()
|
||||||
// Get sha256 digest for tag.
|
imageRef := repoPath + ":" + tag
|
||||||
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2")
|
ref, err := name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRef, c.nameOptions...)
|
||||||
|
if err != nil {
|
||||||
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" {
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRef, err)
|
||||||
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2")
|
return
|
||||||
|
}
|
||||||
|
// Get manifest so we have a digest to delete by
|
||||||
|
descr, err := c.puller.Get(ctx, ref)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Error fetching image reference %s: %s", imageRef, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Parse image reference by digest now
|
||||||
|
imageRefDigest := ref.Context().RepositoryStr() + "@" + descr.Digest.String()
|
||||||
|
ref, err = name.ParseReference(viper.GetString("registry.hostname")+"/"+imageRefDigest, c.nameOptions...)
|
||||||
|
if err != nil {
|
||||||
|
c.logger.Errorf("Error parsing image reference %s: %s", imageRefDigest, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete by manifest digest reference.
|
// Delete tag using digest.
|
||||||
authHeader := ""
|
// Note, it will also delete any other tags pointing to the same digest!
|
||||||
if c.authURL != "" {
|
err = c.pusher.Delete(ctx, ref)
|
||||||
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
|
if err != nil {
|
||||||
}
|
c.logger.Errorf("Error deleting image %s: %s", imageRef, err)
|
||||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest"))
|
return
|
||||||
resp, _, errs := c.request.Delete(c.url+uri).
|
|
||||||
Set("Authorization", authHeader).
|
|
||||||
Set("User-Agent", userAgent).End()
|
|
||||||
if len(errs) > 0 {
|
|
||||||
c.logger.Error(errs[0])
|
|
||||||
} else {
|
|
||||||
// Returns 202 on success.
|
|
||||||
if !strings.Contains(repo, "/") {
|
|
||||||
c.tagCounts["library/"+repo]--
|
|
||||||
} else {
|
|
||||||
c.tagCounts[repo]--
|
|
||||||
}
|
|
||||||
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)
|
|
||||||
}
|
}
|
||||||
|
c.tagCountsMux.Lock()
|
||||||
|
c.tagCounts[repoPath]--
|
||||||
|
c.tagCountsMux.Unlock()
|
||||||
|
c.logger.Infof("Image %s has been successfully deleted.", imageRef)
|
||||||
}
|
}
|
||||||
|
@ -58,3 +58,19 @@ func ItemInSlice(item string, slice []string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UniqueSortedSlice filter out duplicate items from slice
|
||||||
|
func UniqueSortedSlice(slice []string) []string {
|
||||||
|
sort.Strings(slice)
|
||||||
|
seen := make(map[string]struct{}, len(slice))
|
||||||
|
j := 0
|
||||||
|
for _, i := range slice {
|
||||||
|
if _, ok := seen[i]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[i] = struct{}{}
|
||||||
|
slice[j] = i
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
return slice[:j]
|
||||||
|
}
|
||||||
|
@ -34,9 +34,9 @@ func TestSortedMapKeys(t *testing.T) {
|
|||||||
"zoo": "bar",
|
"zoo": "bar",
|
||||||
}
|
}
|
||||||
b := map[string]timeSlice{
|
b := map[string]timeSlice{
|
||||||
"zoo": []tagData{{name: "1", created: time.Now()}},
|
"zoo": []TagData{{name: "1", created: time.Now()}},
|
||||||
"abc": []tagData{{name: "1", created: time.Now()}},
|
"abc": []TagData{{name: "1", created: time.Now()}},
|
||||||
"foo": []tagData{{name: "1", created: time.Now()}},
|
"foo": []TagData{{name: "1", created: time.Now()}},
|
||||||
}
|
}
|
||||||
c := map[string][]string{
|
c := map[string][]string{
|
||||||
"zoo": {"1", "2"},
|
"zoo": {"1", "2"},
|
||||||
|
@ -6,29 +6,23 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PurgeTagsConfig struct {
|
type TagData struct {
|
||||||
DryRun bool
|
|
||||||
KeepDays int
|
|
||||||
KeepMinCount int
|
|
||||||
KeepTagRegexp string
|
|
||||||
KeepFromFile string
|
|
||||||
}
|
|
||||||
|
|
||||||
type tagData struct {
|
|
||||||
name string
|
name string
|
||||||
created time.Time
|
created time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t tagData) String() string {
|
func (t TagData) String() string {
|
||||||
return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05"))
|
return fmt.Sprintf(`"%s <%s>"`, t.name, t.created.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
type timeSlice []tagData
|
type timeSlice []TagData
|
||||||
|
|
||||||
func (p timeSlice) Len() int {
|
func (p timeSlice) Len() int {
|
||||||
return len(p)
|
return len(p)
|
||||||
@ -36,7 +30,7 @@ func (p timeSlice) Len() int {
|
|||||||
|
|
||||||
func (p timeSlice) Less(i, j int) bool {
|
func (p timeSlice) Less(i, j int) bool {
|
||||||
// reverse sort tags on name if equal dates (OCI image case)
|
// reverse sort tags on name if equal dates (OCI image case)
|
||||||
// see https://github.com/Quiq/docker-registry-ui/pull/62
|
// see https://github.com/Quiq/registry-ui/pull/62
|
||||||
if p[i].created.Equal(p[j].created) {
|
if p[i].created.Equal(p[j].created) {
|
||||||
return p[i].name > p[j].name
|
return p[i].name > p[j].name
|
||||||
}
|
}
|
||||||
@ -48,67 +42,83 @@ func (p timeSlice) Swap(i, j int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PurgeOldTags purge old tags.
|
// PurgeOldTags purge old tags.
|
||||||
func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
|
func PurgeOldTags(client *Client, purgeDryRun bool, purgeIncludeRepos, purgeExcludeRepos string) {
|
||||||
logger := SetupLogging("registry.tasks.PurgeOldTags")
|
logger := SetupLogging("registry.tasks.PurgeOldTags")
|
||||||
|
keepDays := viper.GetInt("purge_tags.keep_days")
|
||||||
var keepTagsFromFile gjson.Result
|
keepCount := viper.GetInt("purge_tags.keep_count")
|
||||||
if config.KeepFromFile != "" {
|
keepRegexp := viper.GetString("purge_tags.keep_regexp")
|
||||||
if _, err := os.Stat(config.KeepFromFile); os.IsNotExist(err) {
|
keepFromFile := viper.GetString("purge_tags.keep_from_file")
|
||||||
logger.Warnf("Cannot open %s: %s", config.KeepFromFile, err)
|
|
||||||
logger.Error("Not purging anything!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(config.KeepFromFile)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warnf("Cannot read %s: %s", config.KeepFromFile, err)
|
|
||||||
logger.Error("Not purging anything!")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
keepTagsFromFile = gjson.ParseBytes(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
dryRunText := ""
|
dryRunText := ""
|
||||||
if config.DryRun {
|
if purgeDryRun {
|
||||||
logger.Warn("Dry-run mode enabled.")
|
logger.Warn("Dry-run mode enabled.")
|
||||||
dryRunText = "skipped"
|
dryRunText = "skipped"
|
||||||
}
|
}
|
||||||
logger.Info("Scanning registry for repositories, tags and their creation dates...")
|
|
||||||
catalog := client.Repositories(true)
|
var dataFromFile gjson.Result
|
||||||
// catalog := map[string][]string{"library": []string{""}}
|
if keepFromFile != "" {
|
||||||
|
if _, err := os.Stat(keepFromFile); os.IsNotExist(err) {
|
||||||
|
logger.Warnf("Cannot open %s: %s", keepFromFile, err)
|
||||||
|
logger.Error("Not purging anything!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(keepFromFile)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("Cannot read %s: %s", keepFromFile, err)
|
||||||
|
logger.Error("Not purging anything!")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataFromFile = gjson.ParseBytes(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
catalog := []string{}
|
||||||
|
if purgeIncludeRepos != "" {
|
||||||
|
logger.Infof("Including repositories: %s", purgeIncludeRepos)
|
||||||
|
catalog = append(catalog, strings.Split(purgeIncludeRepos, ",")...)
|
||||||
|
} else {
|
||||||
|
client.RefreshCatalog()
|
||||||
|
catalog = client.GetRepos()
|
||||||
|
}
|
||||||
|
if purgeExcludeRepos != "" {
|
||||||
|
logger.Infof("Excluding repositories: %s", purgeExcludeRepos)
|
||||||
|
tmpCatalog := []string{}
|
||||||
|
for _, repo := range catalog {
|
||||||
|
if !ItemInSlice(repo, strings.Split(purgeExcludeRepos, ",")) {
|
||||||
|
tmpCatalog = append(tmpCatalog, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catalog = tmpCatalog
|
||||||
|
}
|
||||||
|
logger.Infof("Working on repositories: %s", catalog)
|
||||||
|
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
repos := map[string]timeSlice{}
|
repos := map[string]timeSlice{}
|
||||||
count := 0
|
count := 0
|
||||||
for namespace := range catalog {
|
for _, repo := range catalog {
|
||||||
count = count + len(catalog[namespace])
|
tags := client.ListTags(repo)
|
||||||
for _, repo := range catalog[namespace] {
|
|
||||||
if namespace != "library" {
|
|
||||||
repo = fmt.Sprintf("%s/%s", namespace, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := client.Tags(repo)
|
|
||||||
if len(tags) == 0 {
|
if len(tags) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
|
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
|
||||||
for _, tag := range tags {
|
for _, tag := range tags {
|
||||||
_, infoV1, _ := client.TagInfo(repo, tag, true)
|
imageRef := repo + ":" + tag
|
||||||
if infoV1 == "" {
|
created := client.GetImageCreated(imageRef)
|
||||||
logger.Errorf("[%s] missing manifest v1 for tag %s", repo, tag)
|
if created.IsZero() {
|
||||||
|
// Image manifest with zero creation time, e.g. cosign w/o --record-creation-timestamp
|
||||||
|
logger.Debugf("[%s] tag with zero creation time: %s", repo, tag)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").Time()
|
repos[repo] = append(repos[repo], TagData{name: tag, created: created})
|
||||||
repos[repo] = append(repos[repo], tagData{name: tag, created: created})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Infof("Scanned %d repositories.", count)
|
logger.Infof("Scanned %d repositories.", len(catalog))
|
||||||
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", config.KeepDays, config.KeepMinCount)
|
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", keepDays, keepCount)
|
||||||
if config.KeepTagRegexp != "" {
|
if keepRegexp != "" {
|
||||||
logger.Infof("Keeping tags matching regexp: %s", config.KeepTagRegexp)
|
logger.Infof("Keeping tags matching regexp: %s", keepRegexp)
|
||||||
}
|
}
|
||||||
if config.KeepFromFile != "" {
|
if keepFromFile != "" {
|
||||||
logger.Infof("Keeping tags for repos from the file: %+v", keepTagsFromFile)
|
logger.Infof("Keeping tags from file: %+v", dataFromFile)
|
||||||
}
|
}
|
||||||
purgeTags := map[string][]string{}
|
purgeTags := map[string][]string{}
|
||||||
keepTags := map[string][]string{}
|
keepTags := map[string][]string{}
|
||||||
@ -119,19 +129,19 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
|
|||||||
|
|
||||||
// Prep the list of tags to preserve if defined in the file
|
// Prep the list of tags to preserve if defined in the file
|
||||||
tagsFromFile := []string{}
|
tagsFromFile := []string{}
|
||||||
for _, i := range keepTagsFromFile.Get(repo).Array() {
|
for _, i := range dataFromFile.Get(repo).Array() {
|
||||||
tagsFromFile = append(tagsFromFile, i.String())
|
tagsFromFile = append(tagsFromFile, i.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out tags
|
// Filter out tags
|
||||||
for _, tag := range repos[repo] {
|
for _, tag := range repos[repo] {
|
||||||
daysOld := int(now.Sub(tag.created).Hours() / 24)
|
daysOld := int(now.Sub(tag.created).Hours() / 24)
|
||||||
keepByRegexp := false
|
matchByRegexp := false
|
||||||
if config.KeepTagRegexp != "" {
|
if keepRegexp != "" {
|
||||||
keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name)
|
matchByRegexp, _ = regexp.MatchString(keepRegexp, tag.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
if daysOld > config.KeepDays && !keepByRegexp && !ItemInSlice(tag.name, tagsFromFile) {
|
if daysOld > keepDays && !matchByRegexp && !ItemInSlice(tag.name, tagsFromFile) {
|
||||||
purgeTags[repo] = append(purgeTags[repo], tag.name)
|
purgeTags[repo] = append(purgeTags[repo], tag.name)
|
||||||
} else {
|
} else {
|
||||||
keepTags[repo] = append(keepTags[repo], tag.name)
|
keepTags[repo] = append(keepTags[repo], tag.name)
|
||||||
@ -139,9 +149,9 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Keep minimal count of tags no matter how old they are.
|
// Keep minimal count of tags no matter how old they are.
|
||||||
if len(keepTags[repo]) < config.KeepMinCount {
|
if len(keepTags[repo]) < keepCount {
|
||||||
// At least "threshold"-"keep" but not more than available for "purge".
|
// At least "threshold"-"keep" but not more than available for "purge".
|
||||||
takeFromPurge := int(math.Min(float64(config.KeepMinCount-len(keepTags[repo])), float64(len(purgeTags[repo]))))
|
takeFromPurge := int(math.Min(float64(keepCount-len(keepTags[repo])), float64(len(purgeTags[repo]))))
|
||||||
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...)
|
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...)
|
||||||
purgeTags[repo] = purgeTags[repo][takeFromPurge:]
|
purgeTags[repo] = purgeTags[repo][takeFromPurge:]
|
||||||
}
|
}
|
||||||
@ -162,7 +172,7 @@ func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
|
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
|
||||||
if config.DryRun {
|
if purgeDryRun {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, tag := range purgeTags[repo] {
|
for _, tag := range purgeTags[repo] {
|
||||||
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 192 KiB |
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 236 KiB |
Before Width: | Height: | Size: 265 KiB After Width: | Height: | Size: 290 KiB |
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 305 KiB |
5
static/css/bootstrap-icons.min.css
vendored
Normal file
22
static/css/datatables.min.css
vendored
Normal file
BIN
static/css/fonts/bootstrap-icons.woff
Normal file
BIN
static/css/fonts/bootstrap-icons.woff2
Normal file
22
static/datatables.min.css
vendored
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 106 KiB |
65
template.go
@ -3,13 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/url"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/CloudyKit/jet"
|
"github.com/CloudyKit/jet/v6"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/quiq/docker-registry-ui/registry"
|
"github.com/quiq/registry-ui/registry"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Template Jet template.
|
// Template Jet template.
|
||||||
@ -35,41 +34,45 @@ func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupRenderer template engine init.
|
// setupRenderer template engine init.
|
||||||
func setupRenderer(debug bool, registryHost, basePath string) *Template {
|
func setupRenderer(basePath string) *Template {
|
||||||
view := jet.NewHTMLSet("templates")
|
var opts []jet.Option
|
||||||
view.SetDevelopmentMode(debug)
|
if viper.GetBool("debug.templates") {
|
||||||
|
opts = append(opts, jet.InDevelopmentMode())
|
||||||
|
}
|
||||||
|
view := jet.NewSet(jet.NewOSFileSystemLoader("templates"), opts...)
|
||||||
|
|
||||||
view.AddGlobal("version", version)
|
view.AddGlobal("version", version)
|
||||||
view.AddGlobal("basePath", basePath)
|
view.AddGlobal("basePath", basePath)
|
||||||
view.AddGlobal("registryHost", registryHost)
|
view.AddGlobal("registryHost", viper.GetString("registry.hostname"))
|
||||||
view.AddGlobal("pretty_size", func(size interface{}) string {
|
view.AddGlobal("pretty_size", func(val interface{}) string {
|
||||||
var value float64
|
var s float64
|
||||||
switch i := size.(type) {
|
switch i := val.(type) {
|
||||||
case gjson.Result:
|
|
||||||
value = float64(i.Int())
|
|
||||||
case int64:
|
case int64:
|
||||||
value = float64(i)
|
s = float64(i)
|
||||||
|
case float64:
|
||||||
|
s = i
|
||||||
|
default:
|
||||||
|
fmt.Printf("Unhandled type when calling pretty_size(): %T\n", i)
|
||||||
}
|
}
|
||||||
return registry.PrettySize(value)
|
return registry.PrettySize(s)
|
||||||
})
|
})
|
||||||
view.AddGlobal("pretty_time", func(timeVal interface{}) string {
|
view.AddGlobal("pretty_time", func(val interface{}) string {
|
||||||
t, _ := time.Parse("2006-01-02T15:04:05Z", timeVal.(string))
|
var t time.Time
|
||||||
|
switch i := val.(type) {
|
||||||
|
case string:
|
||||||
|
var err error
|
||||||
|
t, err = time.Parse("2006-01-02T15:04:05Z", i)
|
||||||
|
if err != nil {
|
||||||
|
// mysql case
|
||||||
|
t, _ = time.Parse("2006-01-02 15:04:05", i)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t = i.(time.Time)
|
||||||
|
}
|
||||||
return t.In(time.Local).Format("2006-01-02 15:04:05 MST")
|
return t.In(time.Local).Format("2006-01-02 15:04:05 MST")
|
||||||
})
|
})
|
||||||
view.AddGlobal("parse_map", func(m interface{}) string {
|
view.AddGlobal("sort_map_keys", func(m interface{}) []string {
|
||||||
var res string
|
return registry.SortedMapKeys(m)
|
||||||
for _, k := range registry.SortedMapKeys(m) {
|
|
||||||
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
})
|
})
|
||||||
view.AddGlobal("url_decode", func(m interface{}) string {
|
|
||||||
res, err := url.PathUnescape(m.(string))
|
|
||||||
if err != nil {
|
|
||||||
return m.(string)
|
|
||||||
}
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
|
|
||||||
return &Template{View: view}
|
return &Template{View: view}
|
||||||
}
|
}
|
||||||
|
@ -4,19 +4,20 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Docker Registry UI</title>
|
<title>Registry UI</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/datatables.min.css"/>
|
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/css/bootstrap-icons.min.css">
|
||||||
<script type="text/javascript" src="{{ basePath }}/static/datatables.min.js"></script>
|
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/css/datatables.min.css"/>
|
||||||
|
<script type="text/javascript" src="{{ basePath }}/static/js/datatables.min.js"></script>
|
||||||
{{yield head()}}
|
{{yield head()}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div style="float: left">
|
<div style="float: left">
|
||||||
<h2><a href="{{ basePath }}/" style="text-decoration: none">Docker Registry UI</a></h2>
|
<h2><a href="{{ basePath }}/" style="text-decoration: none"><i class="bi-journals"></i> Registry UI</a></h2>
|
||||||
</div>
|
</div>
|
||||||
{{if eventsAllowed}}
|
{{if eventsAllowed}}
|
||||||
<div style="float: right">
|
<div style="float: right">
|
||||||
<h4><a href="{{ basePath }}/events">Event Log</a></h4>
|
<h4><a href="{{ basePath }}/event-log" style="text-decoration: none"><i class="bi-calendar-week"></i> Event Log</a></h4>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div style="clear: both"></div>
|
<div style="clear: both"></div>
|
||||||
@ -25,7 +26,7 @@
|
|||||||
|
|
||||||
<div style="padding: 10px 0; margin-bottom: 20px">
|
<div style="padding: 10px 0; margin-bottom: 20px">
|
||||||
<div style="text-align: center; color:darkgrey">
|
<div style="text-align: center; color:darkgrey">
|
||||||
Docker Registry UI v{{version}} | <a href="https://quiq.com">Quiq Inc.</a>
|
Registry UI v{{version}} | <a href="https://quiq.com" target="_blank">Quiq Inc.</a> | <a href="https://github.com/Quiq/registry-ui" target="_blank">Github</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
10
templates/breadcrumb.html
Normal file
@ -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
@ -0,0 +1,121 @@
|
|||||||
|
{{extends "base.html"}}
|
||||||
|
{{import "breadcrumb.html"}}
|
||||||
|
|
||||||
|
{{block head()}}
|
||||||
|
<script type="text/javascript" src="{{ basePath }}/static/js/bootstrap-confirmation.min.js"></script>
|
||||||
|
<script type="text/javascript" src="{{ basePath }}/static/js/sorting_natural.js"></script>
|
||||||
|
<script type="text/javascript">
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#datatable_repos').DataTable({
|
||||||
|
"pageLength": 10,
|
||||||
|
"stateSave": true,
|
||||||
|
"language": {
|
||||||
|
"emptyTable": "Catalog is being initializing..."
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#datatable_tags').DataTable({
|
||||||
|
"pageLength": 10,
|
||||||
|
"order": [[ 0, 'desc' ]],
|
||||||
|
"stateSave": true,
|
||||||
|
columnDefs: [
|
||||||
|
{ type: 'natural', targets: 0 }
|
||||||
|
],
|
||||||
|
"language": {
|
||||||
|
"emptyTable": "No tags."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
function populateConfirmation() {
|
||||||
|
$('[data-toggle=confirmation]').confirmation({
|
||||||
|
rootSelector: '[data-toggle=confirmation]',
|
||||||
|
container: 'body'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
populateConfirmation()
|
||||||
|
$('#datatable_tags').on('draw.dt', populateConfirmation)
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{block body()}}
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
{{ yield breadcrumb() repoPath }}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{if len(repos)>0 || !isCatalogReady}}
|
||||||
|
<h4>List of Repositories</h4>
|
||||||
|
<table id="datatable_repos" class="table table-striped table-bordered dataTables_wrapper">
|
||||||
|
<thead bgcolor="#ddd">
|
||||||
|
<tr>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th width="20%">Tags</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range _, repo := repos}}
|
||||||
|
{{ full_repo_path := repoPath != "" ? repoPath+"/"+repo : repo }}
|
||||||
|
{{if !isset(tagCounts[full_repo_path]) || (isset(tagCounts[full_repo_path]) && tagCounts[full_repo_path] > 0)}}
|
||||||
|
<tr>
|
||||||
|
<td><i class="bi bi-folder2" style="margin-right: 10px"></i> <a href="{{ basePath }}/{{ full_repo_path }}">{{ repo }}</a></td>
|
||||||
|
<td>{{ tagCounts[full_repo_path] }}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}} {* end repos *}
|
||||||
|
|
||||||
|
{{if len(tags)>0}}
|
||||||
|
<h4>List of Tags</h4>
|
||||||
|
<table id="datatable_tags" class="table table-striped table-bordered">
|
||||||
|
<thead bgcolor="#ddd">
|
||||||
|
<tr>
|
||||||
|
<th>Tag Name</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range _, tag := tags}}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<i class="bi bi-file-text" style="margin-right: 10px"></i> <a href="{{ basePath }}/{{ repoPath }}:{{ tag }}">{{ tag }}</a>
|
||||||
|
{{if deleteAllowed}}
|
||||||
|
<a href="{{ basePath }}/delete-tag?repoPath={{ repoPath }}&tag={{ tag }}" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
|
||||||
|
{{end}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}} {* end tags *}
|
||||||
|
|
||||||
|
{{if eventsAllowed and isset(events) }}
|
||||||
|
<h4>Recent Activity</h4>
|
||||||
|
<table id="datatable_events" class="table table-striped table-bordered">
|
||||||
|
<thead bgcolor="#ddd">
|
||||||
|
<tr>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Image</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range _, e := events}}
|
||||||
|
<tr>
|
||||||
|
<td>{{ e.Action }}</td>
|
||||||
|
{{if hasPrefix(e.Tag,"sha256:") }}
|
||||||
|
<td title="{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</td>
|
||||||
|
{{else}}
|
||||||
|
<td>{{ e.Repository }}:{{ e.Tag }}</td>
|
||||||
|
{{end}}
|
||||||
|
<td>{{ e.IP }}</td>
|
||||||
|
<td>{{ e.User }}</td>
|
||||||
|
<td>{{ e.Created|pretty_time }}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{end}}
|
@ -1,26 +1,63 @@
|
|||||||
{{extends "base.html"}}
|
{{extends "base.html"}}
|
||||||
|
{{import "breadcrumb.html"}}
|
||||||
|
|
||||||
{{block head()}}
|
{{block head()}}
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
$('#datatable').DataTable({
|
var table = $('#datatable').DataTable({
|
||||||
"pageLength": 10,
|
"pageLength": 10,
|
||||||
"order": [[ 4, 'desc' ]],
|
"order": [[ 4, 'desc' ]],
|
||||||
"stateSave": true,
|
"stateSave": false,
|
||||||
|
"searchCols": [
|
||||||
|
null,
|
||||||
|
{search: $('input:checkbox[name="sha256_chk"]').val()},
|
||||||
|
],
|
||||||
"language": {
|
"language": {
|
||||||
"emptyTable": "No events."
|
"emptyTable": "No events."
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$.fn.dataTable.ext.search.push(function( settings, searchData, index, rowData, counter ) {
|
||||||
|
var action = $('input:checkbox[name="action_chk"]:checked').map(function() {
|
||||||
|
return this.value;
|
||||||
|
}).get();
|
||||||
|
if (action.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (action.indexOf(searchData[0]) !== -1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
$('input:checkbox[name="action_chk"]').on('change', function () {
|
||||||
|
table.draw();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('input:checkbox[name="sha256_chk"]').on('change', function () {
|
||||||
|
if ($(this).prop('checked')) {
|
||||||
|
table.column(1).search($(this).val()).draw() ;
|
||||||
|
} else {
|
||||||
|
table.column(1).search('').draw() ;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{block body()}}
|
{{block body()}}
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
|
{{ yield breadcrumb() }}
|
||||||
<li class="active">Event Log</li>
|
<li class="active">Event Log</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
{{if eventsAllowed}}
|
{{if eventsAllowed}}
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" name="action_chk" value="push">
|
||||||
|
<label class="form-check-label">Hide Pull</label>
|
||||||
|
<label class="form-check-label" style="margin-right:10px"></label>
|
||||||
|
<input class="form-check-input" type="checkbox" name="sha256_chk" value="!@sha256" checked>
|
||||||
|
<label class="form-check-label">Hide sha256 entries</label>
|
||||||
|
</div>
|
||||||
<table id="datatable" class="table table-striped table-bordered">
|
<table id="datatable" class="table table-striped table-bordered">
|
||||||
<thead bgcolor="#ddd">
|
<thead bgcolor="#ddd">
|
||||||
<tr>
|
<tr>
|
||||||
@ -32,13 +69,13 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range e := events}}
|
{{range _, e := events}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ e.Action }}</td>
|
<td>{{ e.Action }}</td>
|
||||||
{{if hasPrefix(e.Tag,"sha256") }}
|
{{if hasPrefix(e.Tag,"sha256:") }}
|
||||||
<td title="{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</td>
|
<td title="{{ e.Tag }}"><a href="{{ basePath }}/{{ e.Repository }}@{{ e.Tag }}">{{ e.Repository }}@{{ e.Tag[:19] }}...</a></td>
|
||||||
{{else}}
|
{{else}}
|
||||||
<td>{{ e.Repository }}:{{ e.Tag }}</td>
|
<td><a href="{{ basePath }}/{{ e.Repository }}:{{ e.Tag }}">{{ e.Repository }}:{{ e.Tag }}</a></td>
|
||||||
{{end}}
|
{{end}}
|
||||||
<td>{{ e.IP }}</td>
|
<td>{{ e.IP }}</td>
|
||||||
<td>{{ e.User }}</td>
|
<td>{{ e.User }}</td>
|
||||||
|
90
templates/image_info.html
Normal file
@ -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}}
|
32
templates/json_to_table.html
Normal 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 }}
|
@ -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}}
|
|
@ -1,140 +0,0 @@
|
|||||||
{{extends "base.html"}}
|
|
||||||
|
|
||||||
{{block head()}}
|
|
||||||
<style>
|
|
||||||
/* col 0 style */
|
|
||||||
td:nth-child(1) {
|
|
||||||
color: #838383;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
/* td: long line wrap */
|
|
||||||
td {
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{block body()}}
|
|
||||||
<ol class="breadcrumb">
|
|
||||||
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
|
|
||||||
{{if namespace != "library"}}
|
|
||||||
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
|
|
||||||
{{end}}
|
|
||||||
<li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li>
|
|
||||||
<li class="active">{{ tag }}</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h4>Image Details</h4>
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead bgcolor="#ddd">
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Summary</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tr>
|
|
||||||
<td width="20%"><b>Image URL</b></td><td>{{ registryHost }}/{{ repoPath }}{{ isDigest ? "@" : ":" }}{{ tag }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>Digest</b></td><td>sha256:{{ sha256 }}</td>
|
|
||||||
</tr>
|
|
||||||
{{if created}}
|
|
||||||
<tr>
|
|
||||||
<td><b>Created On</b></td><td>{{ created|pretty_time }}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
{{if not digestList}}
|
|
||||||
<tr>
|
|
||||||
<td><b>Image Size</b></td><td>{{ imageSize|pretty_size }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>Layer Count</b></td><td>{{ layersCount }}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
<tr>
|
|
||||||
<td><b>Manifest Formats</b></td>
|
|
||||||
<td>{{if not isDigest}}Manifest v2 schema 1{{else}}<font color="#c2c2c2">Manifest v2 schema 1</font>{{end}} |
|
|
||||||
{{if not digestList && layersV2}}Manifest v2 schema 2{{else}}<font color="#c2c2c2">Manifest v2 schema 2</font>{{end}} |
|
|
||||||
{{if digestList}}Manifest List v2 schema 2{{else}}<font color="#c2c2c2">Manifest List v2 schema 2</font>{{end}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{{if digestList}}
|
|
||||||
<h4>Sub-images <!-- Manifest List v2 schema 2: multi-arch or cache image --></h4>
|
|
||||||
{{range index, manifest := digestList}}
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead bgcolor="#ddd">
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Manifest #{{ index+1 }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{{range key := manifest["ordered_keys"]}}
|
|
||||||
<tr>
|
|
||||||
<td width="20%">{{ key }}</td>
|
|
||||||
{{if key == "platform" || key == "annotations"}}
|
|
||||||
<td style="padding: 0">
|
|
||||||
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
|
|
||||||
<!-- Nested range does not work. Iterating via filter over the map. -->
|
|
||||||
{{ manifest[key]|parse_map|raw }}
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
{{else if key == "size"}}
|
|
||||||
<td>{{ manifest[key]|pretty_size }}</td>
|
|
||||||
{{else}}
|
|
||||||
<td>{{ manifest[key]|raw }}</td>
|
|
||||||
{{end}}
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
{{else if layersV2}}
|
|
||||||
<h4>Blobs <!-- Manifest v2 schema 2--></h4>
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead bgcolor="#ddd">
|
|
||||||
<tr>
|
|
||||||
<th>Layer #</th>
|
|
||||||
<th>Digest</th>
|
|
||||||
<th>Size</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{{range index, layer := layersV2}}
|
|
||||||
<tr>
|
|
||||||
<td>{{ len(layersV2)-index }}</td>
|
|
||||||
<td>{{ layer["digest"] }}</td>
|
|
||||||
<td>{{ layer["size"]|pretty_size }}</td>
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{if not isDigest}}
|
|
||||||
<h4>Image History <!-- Manifest v2 schema 1--></h4>
|
|
||||||
{{range index, layer := layersV1}}
|
|
||||||
<table class="table table-striped table-bordered">
|
|
||||||
<thead bgcolor="#ddd">
|
|
||||||
<tr>
|
|
||||||
<th colspan="2">Layer #{{ len(layersV1)-index }}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
{{range key := layer["ordered_keys"]}}
|
|
||||||
<tr>
|
|
||||||
<td width="20%">{{ key }}</td>
|
|
||||||
{{if key == "config" || key == "container_config"}}
|
|
||||||
<td style="padding: 0">
|
|
||||||
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
|
|
||||||
<!-- Nested range does not work. Iterating via filter over the map. -->
|
|
||||||
{{ layer[key]|parse_map|raw }}
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
{{else if key == "created"}}
|
|
||||||
<td>{{ layer[key]|pretty_time }}</td>
|
|
||||||
{{else}}
|
|
||||||
<td>{{ layer[key] }}</td>
|
|
||||||
{{end}}
|
|
||||||
</tr>
|
|
||||||
{{end}}
|
|
||||||
</table>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{end}}
|
|
@ -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}}
|
|
@ -1,3 +1,3 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
const version = "0.9.7"
|
const version = "0.10.4"
|
||||||
|
211
web.go
@ -3,13 +3,12 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/CloudyKit/jet"
|
"github.com/CloudyKit/jet/v6"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/quiq/docker-registry-ui/registry"
|
"github.com/quiq/registry-ui/registry"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const usernameHTTPHeader = "X-WEBAUTH-USER"
|
const usernameHTTPHeader = "X-WEBAUTH-USER"
|
||||||
@ -19,166 +18,90 @@ func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap {
|
|||||||
|
|
||||||
data := jet.VarMap{}
|
data := jet.VarMap{}
|
||||||
data.Set("user", user)
|
data.Set("user", user)
|
||||||
data.Set("eventsAllowed", a.config.AnyoneCanViewEvents || registry.ItemInSlice(user, a.config.Admins))
|
admins := viper.GetStringSlice("access_control.admins")
|
||||||
data.Set("deleteAllowed", a.config.AnyoneCanDelete || registry.ItemInSlice(user, a.config.Admins))
|
data.Set("eventsAllowed", viper.GetBool("access_control.anyone_can_view_events") || registry.ItemInSlice(user, admins))
|
||||||
|
data.Set("deleteAllowed", viper.GetBool("access_control.anyone_can_delete_tags") || registry.ItemInSlice(user, admins))
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *apiClient) viewRepositories(c echo.Context) error {
|
func (a *apiClient) viewCatalog(c echo.Context) error {
|
||||||
namespace := c.Param("namespace")
|
repoPath := strings.Trim(c.Param("repoPath"), "/")
|
||||||
if namespace == "" {
|
// fmt.Println("repoPath:", repoPath)
|
||||||
namespace = "library"
|
|
||||||
}
|
|
||||||
|
|
||||||
repos := a.client.Repositories(true)[namespace]
|
|
||||||
data := a.setUserPermissions(c)
|
|
||||||
data.Set("namespace", namespace)
|
|
||||||
data.Set("namespaces", a.client.Namespaces())
|
|
||||||
data.Set("repos", repos)
|
|
||||||
data.Set("tagCounts", a.client.TagCounts())
|
|
||||||
|
|
||||||
return c.Render(http.StatusOK, "repositories.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiClient) viewTags(c echo.Context) error {
|
|
||||||
namespace := c.Param("namespace")
|
|
||||||
repo := c.Param("repo")
|
|
||||||
repoPath := repo
|
|
||||||
if namespace != "library" {
|
|
||||||
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
tags := a.client.Tags(repoPath)
|
|
||||||
|
|
||||||
data := a.setUserPermissions(c)
|
data := a.setUserPermissions(c)
|
||||||
data.Set("namespace", namespace)
|
|
||||||
data.Set("repo", repo)
|
|
||||||
data.Set("tags", tags)
|
|
||||||
repoPath, _ = url.PathUnescape(repoPath)
|
|
||||||
data.Set("events", a.eventListener.GetEvents(repoPath))
|
|
||||||
|
|
||||||
return c.Render(http.StatusOK, "tags.html", data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *apiClient) viewTagInfo(c echo.Context) error {
|
|
||||||
namespace := c.Param("namespace")
|
|
||||||
repo := c.Param("repo")
|
|
||||||
tag := c.Param("tag")
|
|
||||||
repoPath := repo
|
|
||||||
if namespace != "library" {
|
|
||||||
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve full image info from various versions of manifests
|
|
||||||
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
|
|
||||||
sha256list, manifests := a.client.ManifestList(repoPath, tag)
|
|
||||||
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
|
|
||||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
|
|
||||||
}
|
|
||||||
|
|
||||||
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
|
|
||||||
isDigest := strings.HasPrefix(tag, "sha256:")
|
|
||||||
if len(manifests) > 0 {
|
|
||||||
sha256 = sha256list
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather layers v2
|
|
||||||
var layersV2 []map[string]gjson.Result
|
|
||||||
for _, s := range gjson.Get(infoV2, "layers").Array() {
|
|
||||||
layersV2 = append(layersV2, s.Map())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather layers v1
|
|
||||||
var layersV1 []map[string]interface{}
|
|
||||||
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
|
|
||||||
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
|
|
||||||
// Sort key in the map to show the ordered on UI.
|
|
||||||
m["ordered_keys"] = registry.SortedMapKeys(m)
|
|
||||||
layersV1 = append(layersV1, m)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count image size
|
|
||||||
var imageSize int64
|
|
||||||
if gjson.Get(infoV2, "layers").Exists() {
|
|
||||||
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
|
|
||||||
imageSize = imageSize + s.Int()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
|
|
||||||
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count layers
|
|
||||||
layersCount := len(layersV2)
|
|
||||||
if layersCount == 0 {
|
|
||||||
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather sub-image info of multi-arch or cache image
|
|
||||||
var digestList []map[string]interface{}
|
|
||||||
for _, s := range manifests {
|
|
||||||
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
|
|
||||||
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
|
|
||||||
// Sub-image of the specific arch.
|
|
||||||
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
|
|
||||||
var dSize int64
|
|
||||||
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
|
|
||||||
dSize = dSize + d.Int()
|
|
||||||
}
|
|
||||||
r["size"] = dSize
|
|
||||||
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
|
|
||||||
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
|
|
||||||
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Sub-image of the cache type.
|
|
||||||
r["size"] = s.Get("size").Int()
|
|
||||||
}
|
|
||||||
r["ordered_keys"] = registry.SortedMapKeys(r)
|
|
||||||
digestList = append(digestList, r)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Populate template vars
|
|
||||||
data := a.setUserPermissions(c)
|
|
||||||
data.Set("namespace", namespace)
|
|
||||||
data.Set("repo", repo)
|
|
||||||
data.Set("tag", tag)
|
|
||||||
data.Set("repoPath", repoPath)
|
data.Set("repoPath", repoPath)
|
||||||
data.Set("sha256", sha256)
|
|
||||||
data.Set("imageSize", imageSize)
|
|
||||||
data.Set("created", created)
|
|
||||||
data.Set("layersCount", layersCount)
|
|
||||||
data.Set("layersV2", layersV2)
|
|
||||||
data.Set("layersV1", layersV1)
|
|
||||||
data.Set("isDigest", isDigest)
|
|
||||||
data.Set("digestList", digestList)
|
|
||||||
|
|
||||||
return c.Render(http.StatusOK, "tag_info.html", data)
|
showTags := false
|
||||||
|
showImageInfo := false
|
||||||
|
allRepoPaths := a.client.GetRepos()
|
||||||
|
repos := []string{}
|
||||||
|
if repoPath == "" {
|
||||||
|
// Show all repos
|
||||||
|
for _, r := range allRepoPaths {
|
||||||
|
repos = append(repos, strings.Split(r, "/")[0])
|
||||||
|
}
|
||||||
|
} else if strings.Contains(repoPath, ":") {
|
||||||
|
// Show image info
|
||||||
|
showImageInfo = true
|
||||||
|
} else {
|
||||||
|
for _, r := range allRepoPaths {
|
||||||
|
if r == repoPath {
|
||||||
|
// Show tags
|
||||||
|
showTags = true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(r, repoPath+"/") {
|
||||||
|
// Show sub-repos
|
||||||
|
r = strings.TrimPrefix(r, repoPath+"/")
|
||||||
|
repos = append(repos, strings.Split(r, "/")[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if showImageInfo {
|
||||||
|
// Show image info
|
||||||
|
imageInfo, err := a.client.GetImageInfo(repoPath)
|
||||||
|
if err != nil {
|
||||||
|
basePath := viper.GetString("uri_base_path")
|
||||||
|
return c.Redirect(http.StatusSeeOther, basePath)
|
||||||
|
}
|
||||||
|
data.Set("ii", imageInfo)
|
||||||
|
return c.Render(http.StatusOK, "image_info.html", data)
|
||||||
|
} else {
|
||||||
|
// Show repos, tags or both.
|
||||||
|
repos = registry.UniqueSortedSlice(repos)
|
||||||
|
tags := []string{}
|
||||||
|
if showTags {
|
||||||
|
tags = a.client.ListTags(repoPath)
|
||||||
|
|
||||||
|
}
|
||||||
|
data.Set("repos", repos)
|
||||||
|
data.Set("isCatalogReady", a.client.IsCatalogReady())
|
||||||
|
data.Set("tagCounts", a.client.SubRepoTagCounts(repoPath, repos))
|
||||||
|
data.Set("tags", tags)
|
||||||
|
if repoPath != "" && (len(repos) > 0 || len(tags) > 0) {
|
||||||
|
// Do not show events in the root of catalog.
|
||||||
|
data.Set("events", a.eventListener.GetEvents(repoPath))
|
||||||
|
}
|
||||||
|
return c.Render(http.StatusOK, "catalog.html", data)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *apiClient) deleteTag(c echo.Context) error {
|
func (a *apiClient) deleteTag(c echo.Context) error {
|
||||||
namespace := c.Param("namespace")
|
repoPath := c.QueryParam("repoPath")
|
||||||
repo := c.Param("repo")
|
tag := c.QueryParam("tag")
|
||||||
tag := c.Param("tag")
|
|
||||||
repoPath := repo
|
|
||||||
if namespace != "library" {
|
|
||||||
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
|
|
||||||
}
|
|
||||||
|
|
||||||
data := a.setUserPermissions(c)
|
data := a.setUserPermissions(c)
|
||||||
if data["deleteAllowed"].Bool() {
|
if data["deleteAllowed"].Bool() {
|
||||||
a.client.DeleteTag(repoPath, tag)
|
a.client.DeleteTag(repoPath, tag)
|
||||||
}
|
}
|
||||||
|
basePath := viper.GetString("uri_base_path")
|
||||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
|
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s%s", basePath, repoPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
// viewLog view events from sqlite.
|
// viewLog view events from sqlite.
|
||||||
func (a *apiClient) viewLog(c echo.Context) error {
|
func (a *apiClient) viewEventLog(c echo.Context) error {
|
||||||
data := a.setUserPermissions(c)
|
data := a.setUserPermissions(c)
|
||||||
data.Set("events", a.eventListener.GetEvents(""))
|
data.Set("events", a.eventListener.GetEvents(""))
|
||||||
|
|
||||||
return c.Render(http.StatusOK, "event_log.html", data)
|
return c.Render(http.StatusOK, "event_log.html", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|