Compare commits
69 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
31b16bb17a | ||
|
a1bef247e0 | ||
|
905e8f793b | ||
|
19d15065ed | ||
|
811c9d7da0 | ||
|
a84e06443a | ||
|
828a5b72e3 | ||
|
2d3e770e6f | ||
|
88964cb46e | ||
|
c022edba0f | ||
|
f9899cb785 | ||
|
e1cd96ef12 | ||
|
65c9978bd1 | ||
|
b6398fa33c | ||
|
67d82c7d59 | ||
|
dc7b2e42fc | ||
|
41e74f70a2 | ||
|
905e760956 | ||
|
ee38e35ba6 | ||
|
3d90f7b176 | ||
|
3125554074 | ||
|
d5b6669eee | ||
|
fe0f3e28e8 | ||
|
5bce4ad9c6 | ||
|
7151e7b269 | ||
|
4b6870cc35 | ||
|
a7f824f12c | ||
|
fea503c96b | ||
|
c3cc11f8d4 | ||
|
36187651e0 | ||
|
1b84570645 | ||
|
d46605fc06 | ||
|
1f6801acfb | ||
|
359305046c | ||
|
2cb2802973 | ||
|
35aba0dfc3 | ||
|
32d8ab87bd | ||
|
7a3bc551b3 | ||
|
7a752d3f8d | ||
|
80bdad8c91 | ||
|
734afe56b5 | ||
|
0f6bf65015 | ||
|
7525e87c1a | ||
|
97c189b195 | ||
|
2a0159b73e | ||
|
b563c6d1a1 | ||
|
1b8c502a60 | ||
|
9597890b67 | ||
|
e1656debc5 | ||
|
d1ce70490c | ||
|
e163b5af27 | ||
|
51c9a66195 | ||
|
4047019a3f | ||
|
0a268746c9 | ||
|
aaf65ed718 | ||
|
9a0674ea6b | ||
|
34fe1742cf | ||
|
0889821117 | ||
|
8aa9ac86f7 | ||
|
6b404abeaf | ||
|
16fd6d944f | ||
|
4db9ceb0a6 | ||
|
a8d57564fa | ||
|
a5fc3a5d30 | ||
|
eabcf48494 | ||
|
c5a5fe2e8d | ||
|
20daa971e5 | ||
|
aa8bdb41fc | ||
|
217058cda7 |
89
CHANGELOG.md
Normal file
@@ -0,0 +1,89 @@
|
||||
## Changelog
|
||||
|
||||
### 0.9.4 (2022-04-06)
|
||||
|
||||
* Upgrade Go version to 1.18.0, alpine to 3.15 and other dependencies.
|
||||
* Build docker image with ARM support.
|
||||
|
||||
### 0.9.3 (2021-04-26)
|
||||
|
||||
* Upgrade Go version to 1.16.3, alpine to 3.13 and other dependencies.
|
||||
* Support deletion of manifest lists.
|
||||
|
||||
### 0.9.2 (2020-07-10)
|
||||
|
||||
* Upgrade Go version to 1.14.4, alpine to 3.12 and other dependencies.
|
||||
* Enable default logging for purge tags task.
|
||||
|
||||
### 0.9.1 (2020-02-20)
|
||||
|
||||
* Minor amendments for the tag info page to account the cache type of sub-image.
|
||||
|
||||
### 0.9.0 (2020-02-19)
|
||||
|
||||
* Upgrade Go version to 1.13.7, alpine to 3.11 and other dependencies.
|
||||
* Support Manifest List v2. This enables the proper display of multi-arch images,
|
||||
such as those generated by Docker BuildX or manually (thanks to Christoph Honal @StarGate01).
|
||||
So now we support the following formats: Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2
|
||||
and all their confusing combinations.
|
||||
* Amend representation of the tag info page.
|
||||
* Change logging library, add "-log-level" argument and put most of the logging into DEBUG mode.
|
||||
* You can define timezone when running container by adding `TZ` env var, e.g. "-e TZ=America/Los_Angeles"
|
||||
(thanks to @gminog).
|
||||
* Fix initial ownership of /opt/data dir in Dockerfile.
|
||||
* Hide repositories with 0 tags count.
|
||||
* Compatibility fix with docker_auth v1.5.0.
|
||||
|
||||
### 0.8.2 (2019-07-30)
|
||||
|
||||
* Add event_deletion_enabled option to the config, useful for master-master/cluster setups.
|
||||
* Generate SHA256 from response body if no Docker-Content-Digest header is present, e.g. with AWS ECR.
|
||||
* Bump go version.
|
||||
|
||||
### 0.8.1 (2019-02-20)
|
||||
|
||||
* Add favicon.
|
||||
|
||||
### 0.8.0 (2019-02-19)
|
||||
|
||||
* Use go 1.11.5, alpine 3.9, echo 3.3.10.
|
||||
* Put all static files to the docker image instead of loading from CDN.
|
||||
* Now discover more than 100 repositories (thanks to Yuhi Ishikura @uphy).
|
||||
|
||||
### 0.7.4 (2018-10-30)
|
||||
|
||||
* Switch to Go 1.11 and Go Modules to track dependencies.
|
||||
|
||||
### 0.7.3 (2018-08-14)
|
||||
|
||||
* Add `registry_password_file` option to the config file.
|
||||
* Improve no data message on empty tables on UI.
|
||||
* Show the root namespace "library" in the dropdown even when there are no repos in it.
|
||||
* Switch alpine Docker image to 3.8.
|
||||
|
||||
### 0.7.2 (2018-07-30)
|
||||
|
||||
* Make web root accessible w/o trailing slash when base_path is configured.
|
||||
|
||||
### 0.7.1 (2018-07-18)
|
||||
|
||||
* Fix panic when using MySQL for events storage and no table created yet.
|
||||
|
||||
### 0.7 (2018-07-04)
|
||||
|
||||
* When using MySQL for events storage, do not leak connections.
|
||||
* Last events were not shown when viewing a repo of non-default namespace.
|
||||
* Support repos with slash in the name.
|
||||
* Enable Sonatype Nexus compatibility.
|
||||
* Add `base_path` option to the config to run UI from non-root.
|
||||
* Add built-in cron feature for purging tags task.
|
||||
|
||||
### 0.6 (2018-05-28)
|
||||
|
||||
* Add MySQL along with sqlite3 support as a registry events storage.
|
||||
New config settings `event_database_driver`, `event_database_location`.
|
||||
* Bump Go version and dependencies.
|
||||
|
||||
### 0.5 (2018-03-06)
|
||||
|
||||
* Initial public version.
|
28
Dockerfile
@@ -1,27 +1,23 @@
|
||||
FROM golang:1.10.0-alpine3.7 as builder
|
||||
|
||||
ENV GOPATH /opt
|
||||
FROM golang:1.18.0-alpine3.15 as builder
|
||||
|
||||
RUN apk update && \
|
||||
apk add ca-certificates git build-base && \
|
||||
go get github.com/Masterminds/glide
|
||||
apk add ca-certificates git bash gcc musl-dev
|
||||
|
||||
ADD glide.* /opt/src/github.com/quiq/docker-registry-ui/
|
||||
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
|
||||
/opt/bin/glide install
|
||||
WORKDIR /opt/src
|
||||
ADD events events
|
||||
ADD registry registry
|
||||
ADD *.go go.mod go.sum ./
|
||||
|
||||
ADD registry /opt/src/github.com/quiq/docker-registry-ui/registry
|
||||
ADD main.go /opt/src/github.com/quiq/docker-registry-ui/
|
||||
RUN cd /opt/src/github.com/quiq/docker-registry-ui && \
|
||||
go test -v ./registry && \
|
||||
go build -o /opt/docker-registry-ui github.com/quiq/docker-registry-ui
|
||||
RUN go test -v ./registry && \
|
||||
go build -o /opt/docker-registry-ui *.go
|
||||
|
||||
|
||||
FROM alpine:3.7
|
||||
FROM alpine:3.15
|
||||
|
||||
WORKDIR /opt
|
||||
RUN apk add --no-cache ca-certificates && \
|
||||
mkdir /opt/data
|
||||
RUN apk add --no-cache ca-certificates tzdata && \
|
||||
mkdir /opt/data && \
|
||||
chown nobody /opt/data
|
||||
|
||||
ADD templates /opt/templates
|
||||
ADD static /opt/static
|
||||
|
@@ -1,4 +1,4 @@
|
||||
Copyright 2017-2018 Quiq Inc.
|
||||
Copyright 2017-2020 Quiq Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
7
Makefile
Executable file
@@ -0,0 +1,7 @@
|
||||
IMAGE=quiq/docker-registry-ui
|
||||
VERSION=`sed -n '/version/ s/.* = //;s/"//g p' version.go`
|
||||
|
||||
.DEFAULT: buildx
|
||||
|
||||
buildx:
|
||||
@docker buildx build --platform linux/amd64,linux/arm64 -t ${IMAGE}:${VERSION} -t ${IMAGE}:latest --push .
|
74
README.md
@@ -4,13 +4,16 @@
|
||||
|
||||
### Overview
|
||||
|
||||
* Web UI for Docker Registry 2.6+
|
||||
* Browse repositories and tags
|
||||
* Display Docker image details by layers including both manifests v1 and v2
|
||||
* Web UI for Docker Registry
|
||||
* Browse namespaces, repositories and tags
|
||||
* Display image details by layers
|
||||
* Display sub-images of multi-arch or cache type of image
|
||||
* Support Manifest v2 schema 1, Manifest v2 schema 2, Manifest List v2 schema 2 and their confusing combinations
|
||||
* Fast and small, written on Go
|
||||
* Automatically discover an authentication method (basic auth, token service etc.)
|
||||
* Caching the list of repositories, tag counts and refreshing in background
|
||||
* Event listener of notification events coming from Registry
|
||||
* Store events in sqlite or MySQL database
|
||||
* CLI option to maintain the tags retention: purge tags older than X days keeping at least Y tags
|
||||
|
||||
No TLS or authentication implemented on the UI web server itself.
|
||||
@@ -24,7 +27,7 @@ The configuration is stored in `config.yml` and the options are self-descriptive
|
||||
|
||||
### Run UI
|
||||
|
||||
docker run -d -p 8000:8000 --read-only -v /local/config.yml:/opt/config.yml:ro \
|
||||
docker run -d -p 8000:8000 -v /local/config.yml:/opt/config.yml:ro \
|
||||
--name=registry-ui quiq/docker-registry-ui
|
||||
|
||||
To run with your own root CA certificate, add to the command:
|
||||
@@ -35,6 +38,15 @@ To preserve sqlite db file with event notifications data, add to the command:
|
||||
|
||||
-v /local/data:/opt/data
|
||||
|
||||
Ensure /local/data is owner by nobody (alpine user id is 65534).
|
||||
|
||||
You can also run the container with `--read-only` option, however when using using event listener functionality
|
||||
you need to ensure the sqlite db can be written, i.e. mount a folder as listed above (rw mode).
|
||||
|
||||
To run with a custom TZ:
|
||||
|
||||
-e TZ=America/Los_Angeles
|
||||
|
||||
## Configure event listener on Docker Registry
|
||||
|
||||
To receive events you need to configure Registry as follow:
|
||||
@@ -52,9 +64,33 @@ To receive events you need to configure Registry as follow:
|
||||
- application/octet-stream
|
||||
|
||||
Adjust url and token as appropriate.
|
||||
If you are running UI from non-root base path, e.g. /ui, the URL path for above will be `/ui/api/events`.
|
||||
|
||||
## Using MySQL instead of sqlite3 for event listener
|
||||
|
||||
To use MySQL as a storage you need to change `event_database_driver` and `event_database_location`
|
||||
settings in the config file. It is expected you create a database mentioned in the location DSN.
|
||||
Minimal privileges are `SELECT`, `INSERT`, `DELETE`.
|
||||
You can create a table manually if you don't want to grant `CREATE` permission:
|
||||
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
action CHAR(4) NULL,
|
||||
repository VARCHAR(100) NULL,
|
||||
tag VARCHAR(100) NULL,
|
||||
ip VARCHAR(15) NULL,
|
||||
user VARCHAR(50) NULL,
|
||||
created DATETIME NULL
|
||||
);
|
||||
|
||||
### Schedule a cron task for purging tags
|
||||
|
||||
To delete tags you need to enable the corresponding option in Docker Registry config. For example:
|
||||
|
||||
storage:
|
||||
delete:
|
||||
enabled: true
|
||||
|
||||
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.
|
||||
|
||||
@@ -64,12 +100,42 @@ You can try to run in dry-run mode first to see what is going to be purged:
|
||||
|
||||
docker exec -t registry-ui /opt/docker-registry-ui -purge-tags -dry-run
|
||||
|
||||
Alternatively, you can schedule the purging task with built-in cron feature:
|
||||
|
||||
purge_tags_keep_days: 90
|
||||
purge_tags_keep_count: 2
|
||||
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
|
||||
|
||||
Repository list / home page:
|
||||
|
||||

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

|
||||
|
||||
Tag info page:
|
||||
|
||||

|
||||
|
||||
Event log page:
|
||||
|
||||

|
||||
|
21
config.yml
@@ -1,5 +1,7 @@
|
||||
# Listen interface.
|
||||
listen_addr: 0.0.0.0:8000
|
||||
# Base path of Docker Registry UI.
|
||||
base_path: /
|
||||
|
||||
# Registry URL with schema and port.
|
||||
registry_url: https://docker-registry.local
|
||||
@@ -10,8 +12,11 @@ verify_tls: true
|
||||
# They need to have a full access to the registry.
|
||||
# If token authentication service is enabled, it will be auto-discovered and those credentials
|
||||
# will be used to obtain access tokens.
|
||||
# When the registry_password_file entry is used, the password can be passed as a docker secret
|
||||
# and read from file. This overides the registry_password entry.
|
||||
registry_username: user
|
||||
registry_password: pass
|
||||
# registry_password_file: /run/secrets/registry_password_file
|
||||
|
||||
# Event listener token.
|
||||
# The same one should be configured on Docker registry as Authorization Bearer token.
|
||||
@@ -19,6 +24,16 @@ event_listener_token: token
|
||||
# Retention of records to keep.
|
||||
event_retention_days: 7
|
||||
|
||||
# Event listener storage.
|
||||
event_database_driver: sqlite3
|
||||
event_database_location: data/registry_events.db
|
||||
# event_database_driver: mysql
|
||||
# event_database_location: user:password@tcp(localhost:3306)/docker_events
|
||||
|
||||
# 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
|
||||
@@ -32,7 +47,11 @@ admins: []
|
||||
# Debug mode. Affects only templates.
|
||||
debug: true
|
||||
|
||||
# CLI options.
|
||||
# How many days to keep tags but also keep the minimal count provided no matter how old.
|
||||
purge_tags_keep_days: 90
|
||||
purge_tags_keep_count: 2
|
||||
# Enable built-in cron to schedule purging tags in server mode.
|
||||
# Empty string disables this feature.
|
||||
# Example: '25 54 17 * * *' will run it at 17:54:25 daily.
|
||||
# Note, the cron schedule format includes seconds! See https://godoc.org/github.com/robfig/cron
|
||||
purge_tags_schedule: ''
|
||||
|
196
events/event_listener.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/quiq/docker-registry-ui/registry"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
// 🐒 patching of "database/sql".
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
schemaSQLite = `
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action CHAR(5) NULL,
|
||||
repository VARCHAR(100) NULL,
|
||||
tag VARCHAR(100) NULL,
|
||||
ip VARCHAR(15) NULL,
|
||||
user VARCHAR(50) NULL,
|
||||
created DATETIME NULL
|
||||
);
|
||||
`
|
||||
)
|
||||
|
||||
// EventListener event listener
|
||||
type EventListener struct {
|
||||
databaseDriver string
|
||||
databaseLocation string
|
||||
retention int
|
||||
eventDeletion bool
|
||||
logger *logrus.Entry
|
||||
}
|
||||
|
||||
type eventData struct {
|
||||
Events []interface{} `json:"events"`
|
||||
}
|
||||
|
||||
// EventRow event row from sqlite
|
||||
type EventRow struct {
|
||||
ID int
|
||||
Action string
|
||||
Repository string
|
||||
Tag string
|
||||
IP string
|
||||
User string
|
||||
Created string
|
||||
}
|
||||
|
||||
// NewEventListener initialize EventListener.
|
||||
func NewEventListener(databaseDriver, databaseLocation string, retention int, eventDeletion bool) *EventListener {
|
||||
return &EventListener{
|
||||
databaseDriver: databaseDriver,
|
||||
databaseLocation: databaseLocation,
|
||||
retention: retention,
|
||||
eventDeletion: eventDeletion,
|
||||
logger: registry.SetupLogging("events.event_listener"),
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessEvents parse and store registry events
|
||||
func (e *EventListener) ProcessEvents(request *http.Request) {
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
var t eventData
|
||||
if err := decoder.Decode(&t); err != nil {
|
||||
e.logger.Errorf("Problem decoding event from request: %+v", request)
|
||||
return
|
||||
}
|
||||
e.logger.Debugf("Received event: %+v", t)
|
||||
j, _ := json.Marshal(t)
|
||||
|
||||
db, err := e.getDatabaseHandler()
|
||||
if err != nil {
|
||||
e.logger.Error(err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := "DateTime('now')"
|
||||
if e.databaseDriver == "mysql" {
|
||||
now = "NOW()"
|
||||
}
|
||||
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?," + now + ")")
|
||||
for _, i := range gjson.GetBytes(j, "events").Array() {
|
||||
// Ignore calls by docker-registry-ui itself.
|
||||
if i.Get("request.useragent").String() == "docker-registry-ui" {
|
||||
continue
|
||||
}
|
||||
action := i.Get("action").String()
|
||||
repository := i.Get("target.repository").String()
|
||||
tag := i.Get("target.tag").String()
|
||||
// Tag is empty in case of signed pull.
|
||||
if tag == "" {
|
||||
tag = i.Get("target.digest").String()
|
||||
}
|
||||
ip := strings.Split(i.Get("request.addr").String(), ":")[0]
|
||||
user := i.Get("actor.name").String()
|
||||
e.logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
|
||||
|
||||
res, err := stmt.Exec(action, repository, tag, ip, user)
|
||||
if err != nil {
|
||||
e.logger.Error("Error inserting a row: ", err)
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
e.logger.Debug("New event added with id ", id)
|
||||
}
|
||||
|
||||
// Purge old records.
|
||||
if !e.eventDeletion {
|
||||
return
|
||||
}
|
||||
var res sql.Result
|
||||
if e.databaseDriver == "mysql" {
|
||||
stmt, _ := db.Prepare("DELETE FROM events WHERE created < DATE_SUB(NOW(), INTERVAL ? DAY)")
|
||||
res, _ = stmt.Exec(e.retention)
|
||||
} else {
|
||||
stmt, _ := db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)")
|
||||
res, _ = stmt.Exec(fmt.Sprintf("-%d day", e.retention))
|
||||
}
|
||||
count, _ := res.RowsAffected()
|
||||
e.logger.Debug("Rows deleted: ", count)
|
||||
}
|
||||
|
||||
// GetEvents retrieve events from sqlite db
|
||||
func (e *EventListener) GetEvents(repository string) []EventRow {
|
||||
var events []EventRow
|
||||
|
||||
db, err := e.getDatabaseHandler()
|
||||
if err != nil {
|
||||
e.logger.Error(err)
|
||||
return events
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
|
||||
if repository != "" {
|
||||
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository)
|
||||
}
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
e.logger.Error("Error selecting from table: ", err)
|
||||
return events
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var row EventRow
|
||||
rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created)
|
||||
events = append(events, row)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (e *EventListener) getDatabaseHandler() (*sql.DB, error) {
|
||||
firstRun := false
|
||||
schema := schemaSQLite
|
||||
if e.databaseDriver == "sqlite3" {
|
||||
if _, err := os.Stat(e.databaseLocation); os.IsNotExist(err) {
|
||||
firstRun = true
|
||||
}
|
||||
}
|
||||
|
||||
// Open db connection.
|
||||
db, err := sql.Open(e.databaseDriver, e.databaseLocation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Error opening %s db: %s", e.databaseDriver, err)
|
||||
}
|
||||
|
||||
if e.databaseDriver == "mysql" {
|
||||
schema = strings.Replace(schema, "AUTOINCREMENT", "AUTO_INCREMENT", 1)
|
||||
rows, err := db.Query("SELECT * FROM events LIMIT 1")
|
||||
if err != nil {
|
||||
firstRun = true
|
||||
}
|
||||
if rows != nil {
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Create table on first run.
|
||||
if firstRun {
|
||||
if _, err = db.Exec(schema); err != nil {
|
||||
return nil, fmt.Errorf("Error creating a table: %s", err)
|
||||
}
|
||||
}
|
||||
return db, nil
|
||||
}
|
1
example/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/data
|
25
example/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# Docker Registry UI Example
|
||||
|
||||
Start the local Docker Registry and UI.
|
||||
|
||||
```bash
|
||||
$ docker-compose up -d
|
||||
```
|
||||
|
||||
As an example, push the docker-registry-ui image to the local Docker Registry.
|
||||
|
||||
```bash
|
||||
$ docker tag quiq/docker-registry-ui localhost/quiq/docker-registry-ui
|
||||
$ docker push localhost/quiq/docker-registry-ui
|
||||
The push refers to repository [localhost:5000/quiq/docker-registry-ui]
|
||||
ab414a599bf8: Pushed
|
||||
a8da33adf86e: Pushed
|
||||
71a0e0a972a7: Pushed
|
||||
96dc74eb5456: Pushed
|
||||
ac362bf380d0: Pushed
|
||||
04a094fe844e: Pushed
|
||||
latest: digest: sha256:d88c1ca40986a358e59795992e87e364a0b3b97833aade5abcd79dda0a0477e8 size: 1571
|
||||
```
|
||||
|
||||
Then you will find the pushed repository 'quiq/docker-registry-ui' in the following URL.
|
||||
http://localhost/ui/quiq/docker-registry-ui
|
79
example/config/httpd.conf
Normal file
@@ -0,0 +1,79 @@
|
||||
LoadModule mpm_event_module modules/mod_mpm_event.so
|
||||
LoadModule headers_module modules/mod_headers.so
|
||||
|
||||
LoadModule authn_file_module modules/mod_authn_file.so
|
||||
LoadModule authn_core_module modules/mod_authn_core.so
|
||||
LoadModule authz_groupfile_module modules/mod_authz_groupfile.so
|
||||
LoadModule authz_user_module modules/mod_authz_user.so
|
||||
LoadModule authz_core_module modules/mod_authz_core.so
|
||||
LoadModule auth_basic_module modules/mod_auth_basic.so
|
||||
LoadModule access_compat_module modules/mod_access_compat.so
|
||||
|
||||
LoadModule log_config_module modules/mod_log_config.so
|
||||
|
||||
LoadModule proxy_module modules/mod_proxy.so
|
||||
LoadModule proxy_http_module modules/mod_proxy_http.so
|
||||
|
||||
LoadModule unixd_module modules/mod_unixd.so
|
||||
|
||||
<IfModule unixd_module>
|
||||
User daemon
|
||||
Group daemon
|
||||
</IfModule>
|
||||
|
||||
ServerAdmin you@example.com
|
||||
|
||||
ErrorLog /proc/self/fd/2
|
||||
|
||||
LogLevel warn
|
||||
|
||||
<IfModule log_config_module>
|
||||
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\"" combined
|
||||
LogFormat "%h %l %u %t \"%r\" %>s %b" common
|
||||
|
||||
<IfModule logio_module>
|
||||
LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-Agent}i\" %I %O" combinedio
|
||||
</IfModule>
|
||||
|
||||
CustomLog /proc/self/fd/1 common
|
||||
</IfModule>
|
||||
|
||||
ServerRoot "/usr/local/apache2"
|
||||
|
||||
Listen 80
|
||||
|
||||
<Directory />
|
||||
AllowOverride none
|
||||
Require all denied
|
||||
</Directory>
|
||||
|
||||
<VirtualHost *:80>
|
||||
|
||||
ServerName myregistrydomain.com
|
||||
|
||||
Header always set "Docker-Distribution-Api-Version" "registry/2.0"
|
||||
Header onsuccess set "Docker-Distribution-Api-Version" "registry/2.0"
|
||||
|
||||
ProxyRequests off
|
||||
ProxyPreserveHost on
|
||||
|
||||
# no proxy for /error/ (Apache HTTPd errors messages)
|
||||
ProxyPass /error/ !
|
||||
|
||||
ProxyPass /v2 http://registry:5000/v2
|
||||
ProxyPassReverse /v2 http://registry:5000/v2
|
||||
|
||||
<Location /v2>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Location>
|
||||
|
||||
ProxyPass /ui/ http://registry-ui:8000/ui/
|
||||
ProxyPassReverse /ui/ http://registry-ui:8000/ui/
|
||||
|
||||
<Location /ui>
|
||||
Order deny,allow
|
||||
Allow from all
|
||||
</Location>
|
||||
|
||||
</VirtualHost>
|
46
example/config/registry-ui.yml
Normal file
@@ -0,0 +1,46 @@
|
||||
# Listen interface.
|
||||
listen_addr: 0.0.0.0:8000
|
||||
# Base path of Docker Registry UI.
|
||||
base_path: /ui
|
||||
|
||||
# Registry URL with schema and port.
|
||||
registry_url: http://registry:5000
|
||||
# Verify TLS certificate when using https.
|
||||
verify_tls: true
|
||||
|
||||
# Docker registry credentials.
|
||||
# They need to have a full access to the registry.
|
||||
# If token authentication service is enabled, it will be auto-discovered and those credentials
|
||||
# will be used to obtain access tokens.
|
||||
# registry_username: user
|
||||
# registry_password: pass
|
||||
|
||||
# Event listener token.
|
||||
# The same one should be configured on Docker registry as Authorization Bearer token.
|
||||
event_listener_token: token
|
||||
# Retention of records to keep.
|
||||
event_retention_days: 7
|
||||
|
||||
# Event listener storage.
|
||||
event_database_driver: sqlite3
|
||||
event_database_location: data/registry_events.db
|
||||
# event_database_driver: mysql
|
||||
# event_database_location: user:password@tcp(localhost:3306)/docker_events
|
||||
|
||||
# Cache refresh interval in minutes.
|
||||
# How long to cache repository list and tag counts.
|
||||
cache_refresh_interval: 10
|
||||
|
||||
# If users can delete tags. If set to False, then only admins listed below.
|
||||
anyone_can_delete: false
|
||||
# Users allowed to delete tags.
|
||||
# This should be sent via X-WEBAUTH-USER header from your proxy.
|
||||
admins: []
|
||||
|
||||
# Debug mode. Affects only templates.
|
||||
debug: true
|
||||
|
||||
# CLI options.
|
||||
# How many days to keep tags but also keep the minimal count provided no matter how old.
|
||||
purge_tags_keep_days: 90
|
||||
purge_tags_keep_count: 2
|
31
example/docker-compose.yml
Normal file
@@ -0,0 +1,31 @@
|
||||
version: "2.3"
|
||||
|
||||
services:
|
||||
httpd:
|
||||
image: httpd:2.4
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- "./config/httpd.conf:/usr/local/apache2/conf/httpd.conf:ro"
|
||||
|
||||
registry:
|
||||
image: registry:2
|
||||
ports:
|
||||
- "5000"
|
||||
volumes:
|
||||
- "./data/registry:/var/lib/registry"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-s", "localhost:5000/v2/"]
|
||||
interval: 5s
|
||||
timeout: 10s
|
||||
|
||||
registry-ui:
|
||||
image: quiq/docker-registry-ui:latest
|
||||
ports:
|
||||
- "8000"
|
||||
volumes:
|
||||
- "./data/registry-ui:/opt/data"
|
||||
- "./config/registry-ui.yml:/opt/config.yml:ro"
|
||||
depends_on:
|
||||
registry:
|
||||
condition: service_healthy
|
76
glide.lock
generated
@@ -1,76 +0,0 @@
|
||||
hash: 29246065eafa5aaec8848881fb6c99995e91a3f9b0082724db71c71590937e29
|
||||
updated: 2018-02-19T16:47:40.847725+02:00
|
||||
imports:
|
||||
- name: github.com/CloudyKit/fastprinter
|
||||
version: 74b38d55f37af5d6c05ca11147d616b613a3420e
|
||||
- name: github.com/CloudyKit/jet
|
||||
version: 2b064536b25ab0e9c54245f9e2cc5bd4766033fe
|
||||
- name: github.com/dgrijalva/jwt-go
|
||||
version: a539ee1a749a2b895533f979515ac7e6e0f5b650
|
||||
- name: github.com/grafana/grafana
|
||||
version: c4683f1ae85a9c80dfd04fce09318c466885f3c0
|
||||
subpackages:
|
||||
- pkg/cmd/grafana-cli/logger
|
||||
- name: github.com/hhkbp2/go-logging
|
||||
version: 1bf77adfece4a2018ac4bcc84e1f20509157a534
|
||||
- name: github.com/hhkbp2/go-strftime
|
||||
version: d82166ec6782f870431668391c2e321069632fe7
|
||||
- name: github.com/labstack/echo
|
||||
version: b338075a0fc6e1a0683dbf03d09b4957a289e26f
|
||||
subpackages:
|
||||
- middleware
|
||||
- name: github.com/labstack/gommon
|
||||
version: 779b8a8b9850a97acba6a3fe20feb628c39e17c1
|
||||
subpackages:
|
||||
- bytes
|
||||
- color
|
||||
- log
|
||||
- random
|
||||
- name: github.com/mattn/go-colorable
|
||||
version: 3fa8c76f9daed4067e4a806fb7e4dc86455c6d6a
|
||||
- name: github.com/mattn/go-isatty
|
||||
version: fc9e8d8ef48496124e79ae0df75490096eccf6fe
|
||||
- name: github.com/mattn/go-sqlite3
|
||||
version: 6c771bb9887719704b210e87e934f08be014bdb1
|
||||
- name: github.com/moul/http2curl
|
||||
version: 4e24498b31dba4683efb9d35c1c8a91e2eda28c8
|
||||
- name: github.com/parnurzeal/gorequest
|
||||
version: a578a48e8d6ca8b01a3b18314c43c6716bb5f5a3
|
||||
- name: github.com/pkg/errors
|
||||
version: c605e284fe17294bda444b34710735b29d1a9d90
|
||||
- name: github.com/tidwall/gjson
|
||||
version: 87033efcaec6215741137e8ca61952c53ef2685d
|
||||
- name: github.com/tidwall/match
|
||||
version: 173748da739a410c5b0b813b956f89ff94730b4c
|
||||
- name: github.com/valyala/bytebufferpool
|
||||
version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7
|
||||
- name: github.com/valyala/fasttemplate
|
||||
version: dcecefd839c4193db0d35b88ec65b4c12d360ab0
|
||||
- name: golang.org/x/crypto
|
||||
version: 4d70248d17d12d1edb7153434a74001c1540938b
|
||||
subpackages:
|
||||
- acme
|
||||
- acme/autocert
|
||||
- name: golang.org/x/net
|
||||
version: 02ac38e2528ff4adea90f184d71a3faa04b4b1b0
|
||||
subpackages:
|
||||
- publicsuffix
|
||||
- name: golang.org/x/sys
|
||||
version: cd2c276457edda6df7fb04895d3fd6a6add42926
|
||||
subpackages:
|
||||
- unix
|
||||
- name: gopkg.in/yaml.v2
|
||||
version: 3b4ad1db5b2a649883ff3782f5f9f6fb52be71af
|
||||
testImports:
|
||||
- name: github.com/jtolds/gls
|
||||
version: 9a4a02dbe491bef4bab3c24fd9f3087d6c4c6690
|
||||
- name: github.com/smartystreets/assertions
|
||||
version: 01fedaa993c0a9f9aa55111501cd7c81a49e812e
|
||||
subpackages:
|
||||
- internal/oglematchers
|
||||
- name: github.com/smartystreets/goconvey
|
||||
version: d4c757aa9afd1e2fc1832aaab209b5794eb336e1
|
||||
subpackages:
|
||||
- convey
|
||||
- convey/gotest
|
||||
- convey/reporting
|
20
glide.yaml
@@ -1,20 +0,0 @@
|
||||
package: github.com/quiq/docker-registry-ui
|
||||
import:
|
||||
- package: github.com/CloudyKit/jet
|
||||
version: v2.1.2
|
||||
- package: github.com/labstack/echo
|
||||
version: v3.2.6
|
||||
subpackages:
|
||||
- middleware
|
||||
- package: github.com/parnurzeal/gorequest
|
||||
version: v0.2.15
|
||||
- package: github.com/hhkbp2/go-logging
|
||||
- package: github.com/tidwall/gjson
|
||||
version: v1.0.6
|
||||
- package: github.com/mattn/go-sqlite3
|
||||
version: 1.6.0
|
||||
testImport:
|
||||
- package: github.com/smartystreets/goconvey
|
||||
version: 1.6.2
|
||||
subpackages:
|
||||
- convey
|
20
go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module github.com/quiq/docker-registry-ui
|
||||
|
||||
require (
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
|
||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible
|
||||
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/labstack/echo/v4 v4.7.2
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible
|
||||
github.com/parnurzeal/gorequest v0.2.16
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/robfig/cron v1.2.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/smartystreets/goconvey v1.7.2
|
||||
github.com/tidwall/gjson v1.14.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
moul.io/http2curl v1.0.0 // indirect
|
||||
)
|
||||
|
||||
go 1.15
|
93
go.sum
Normal file
@@ -0,0 +1,93 @@
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4slttB4vD+b9btVEnWgL3Q00OBTzVT8B9C0c=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible h1:rZgFj+Gtf3NMi/U5FvCvhzaxzW/TaPYgUYx3bAPz9DE=
|
||||
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e h1:8dhROE/dIrz8nOJQjah6LG37QfL8fZhQTp1RDAjuNpQ=
|
||||
github.com/elazarl/goproxy v0.0.0-20220403042543-a53172b9392e/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM=
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/labstack/echo/v4 v4.7.2 h1:Kv2/p8OaQ+M6Ex4eGimg9b9e6icoxA42JSlOR3msKtI=
|
||||
github.com/labstack/echo/v4 v4.7.2/go.mod h1:xkCDAdFCIf8jsFQ5NnbK7oqaF/yU1A1X20Ltm0OvSks=
|
||||
github.com/labstack/gommon v0.3.1 h1:OomWaJXm7xR6L1HmEtGyQf26TEn7V6X88mktX9kee9o=
|
||||
github.com/labstack/gommon v0.3.1/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
|
||||
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
|
||||
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
|
||||
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
|
||||
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/parnurzeal/gorequest v0.2.16 h1:T/5x+/4BT+nj+3eSknXmCTnEVGSzFzPGdpqmUVVZXHQ=
|
||||
github.com/parnurzeal/gorequest v0.2.16/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
||||
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
|
||||
github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
|
||||
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
|
||||
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
|
||||
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tidwall/gjson v1.14.0 h1:6aeJ0bzojgWLa82gDQHcx3S0Lr/O51I9bJ5nv6JFx5w=
|
||||
github.com/tidwall/gjson v1.14.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasttemplate v1.2.1 h1:TVEnxayobAdVkhQfrfes2IzOB6o+z4roRkPF52WA1u4=
|
||||
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f h1:OfiFi4JbukWwe3lzw+xunroH1mnC1e2Gy5cxNJApiSY=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324 h1:Hir2P/De0WpUhtrKGGjvSb2YxUgyZ7EFOSLIcSSpiwE=
|
||||
golang.org/x/time v0.0.0-20201208040808-7e3f01d25324/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
|
||||
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
|
258
main.go
@@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -11,27 +10,36 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/CloudyKit/jet"
|
||||
"github.com/labstack/echo"
|
||||
"github.com/labstack/echo/middleware"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
"github.com/quiq/docker-registry-ui/events"
|
||||
"github.com/quiq/docker-registry-ui/registry"
|
||||
"github.com/robfig/cron"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type configData struct {
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
RegistryURL string `yaml:"registry_url"`
|
||||
VerifyTLS bool `yaml:"verify_tls"`
|
||||
Username string `yaml:"registry_username"`
|
||||
Password string `yaml:"registry_password"`
|
||||
EventListenerToken string `yaml:"event_listener_token"`
|
||||
EventRetentionDays int `yaml:"event_retention_days"`
|
||||
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
|
||||
AnyoneCanDelete bool `yaml:"anyone_can_delete"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Debug bool `yaml:"debug"`
|
||||
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
|
||||
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
|
||||
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"`
|
||||
Admins []string `yaml:"admins"`
|
||||
Debug bool `yaml:"debug"`
|
||||
PurgeTagsKeepDays int `yaml:"purge_tags_keep_days"`
|
||||
PurgeTagsKeepCount int `yaml:"purge_tags_keep_count"`
|
||||
PurgeTagsSchedule string `yaml:"purge_tags_schedule"`
|
||||
}
|
||||
|
||||
type template struct {
|
||||
@@ -39,22 +47,30 @@ type template struct {
|
||||
}
|
||||
|
||||
type apiClient struct {
|
||||
client *registry.Client
|
||||
config configData
|
||||
client *registry.Client
|
||||
eventListener *events.EventListener
|
||||
config configData
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
a apiClient
|
||||
configFile string
|
||||
purgeTags bool
|
||||
purgeDryRun bool
|
||||
a apiClient
|
||||
|
||||
configFile, loggingLevel string
|
||||
purgeTags, purgeDryRun bool
|
||||
)
|
||||
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
|
||||
flag.StringVar(&loggingLevel, "log-level", "info", "logging level")
|
||||
flag.BoolVar(&purgeTags, "purge-tags", false, "purge old tags instead of running a web server")
|
||||
flag.BoolVar(&purgeDryRun, "dry-run", false, "dry-run for purging task, does not delete anything")
|
||||
flag.Parse()
|
||||
|
||||
if loggingLevel != "info" {
|
||||
if level, err := logrus.ParseLevel(loggingLevel); err == nil {
|
||||
logrus.SetLevel(level)
|
||||
}
|
||||
}
|
||||
|
||||
// Read config file.
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
panic(err)
|
||||
@@ -71,6 +87,26 @@ func main() {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Normalize base path.
|
||||
if a.config.BasePath != "" {
|
||||
if !strings.HasPrefix(a.config.BasePath, "/") {
|
||||
a.config.BasePath = "/" + a.config.BasePath
|
||||
}
|
||||
if strings.HasSuffix(a.config.BasePath, "/") {
|
||||
a.config.BasePath = a.config.BasePath[0 : len(a.config.BasePath)-1]
|
||||
}
|
||||
}
|
||||
// Read password from file.
|
||||
if a.config.PasswordFile != "" {
|
||||
if _, err := os.Stat(a.config.PasswordFile); os.IsNotExist(err) {
|
||||
panic(err)
|
||||
}
|
||||
passwordBytes, err := ioutil.ReadFile(a.config.PasswordFile)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
a.config.Password = strings.TrimSuffix(string(passwordBytes[:]), "\n")
|
||||
}
|
||||
|
||||
// Init registry API client.
|
||||
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
|
||||
@@ -80,78 +116,60 @@ func main() {
|
||||
|
||||
// Execute CLI task and exit.
|
||||
if purgeTags {
|
||||
registry.PurgeOldTags(a.client, purgeDryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount)
|
||||
a.purgeOldTags(purgeDryRun)
|
||||
return
|
||||
}
|
||||
// Schedules to purge tags.
|
||||
if a.config.PurgeTagsSchedule != "" {
|
||||
c := cron.New()
|
||||
task := func() {
|
||||
a.purgeOldTags(purgeDryRun)
|
||||
}
|
||||
if err := c.AddFunc(a.config.PurgeTagsSchedule, task); 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.
|
||||
view := jet.NewHTMLSet("templates")
|
||||
view.SetDevelopmentMode(a.config.Debug)
|
||||
view.AddGlobal("registryHost", u.Host)
|
||||
view.AddGlobal("pretty_size", func(size interface{}) string {
|
||||
var value float64
|
||||
switch i := size.(type) {
|
||||
case gjson.Result:
|
||||
value = float64(i.Int())
|
||||
case int64:
|
||||
value = float64(i)
|
||||
}
|
||||
return registry.PrettySize(value)
|
||||
})
|
||||
view.AddGlobal("pretty_time", func(datetime interface{}) string {
|
||||
return strings.Split(strings.Replace(datetime.(string), "T", " ", 1), ".")[0]
|
||||
})
|
||||
view.AddGlobal("parse_map", func(m interface{}) string {
|
||||
var res string
|
||||
for _, k := range registry.SortedMapKeys(m) {
|
||||
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
|
||||
}
|
||||
return res
|
||||
})
|
||||
e := echo.New()
|
||||
e.Renderer = &template{View: view}
|
||||
e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath)
|
||||
|
||||
// Web routes.
|
||||
e.Static("/static", "static")
|
||||
e.GET("/", a.viewRepositories)
|
||||
e.GET("/:namespace", a.viewRepositories)
|
||||
e.GET("/:namespace/:repo", a.viewTags)
|
||||
e.GET("/:namespace/:repo/:tag", a.viewTagInfo)
|
||||
e.GET("/:namespace/:repo/:tag/delete", a.deleteTag)
|
||||
e.GET("/events", a.viewLog)
|
||||
e.File("/favicon.ico", "static/favicon.ico")
|
||||
e.Static(a.config.BasePath+"/static", "static")
|
||||
if a.config.BasePath != "" {
|
||||
e.GET(a.config.BasePath, a.viewRepositories)
|
||||
}
|
||||
e.GET(a.config.BasePath+"/", a.viewRepositories)
|
||||
e.GET(a.config.BasePath+"/:namespace", a.viewRepositories)
|
||||
e.GET(a.config.BasePath+"/:namespace/:repo", a.viewTags)
|
||||
e.GET(a.config.BasePath+"/:namespace/:repo/:tag", a.viewTagInfo)
|
||||
e.GET(a.config.BasePath+"/:namespace/:repo/:tag/delete", a.deleteTag)
|
||||
e.GET(a.config.BasePath+"/events", a.viewLog)
|
||||
|
||||
// Protected event listener.
|
||||
p := e.Group("/api")
|
||||
p := e.Group(a.config.BasePath + "/api")
|
||||
p.Use(middleware.KeyAuthWithConfig(middleware.KeyAuthConfig{
|
||||
Validator: middleware.KeyAuthValidator(func(token string, c echo.Context) (bool, error) {
|
||||
return token == a.config.EventListenerToken, nil
|
||||
}),
|
||||
}))
|
||||
p.POST("/events", a.eventListener)
|
||||
p.POST("/events", a.receiveEvents)
|
||||
|
||||
e.Logger.Fatal(e.Start(a.config.ListenAddr))
|
||||
}
|
||||
|
||||
// Render render template.
|
||||
func (r *template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
t, err := r.View.GetTemplate(name)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Fatal error template file: %s", err))
|
||||
}
|
||||
vars, ok := data.(jet.VarMap)
|
||||
if !ok {
|
||||
vars = jet.VarMap{}
|
||||
}
|
||||
err = t.Execute(w, vars, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *apiClient) viewRepositories(c echo.Context) error {
|
||||
namespace := c.Param("namespace")
|
||||
if namespace == "" {
|
||||
@@ -159,7 +177,6 @@ func (a *apiClient) viewRepositories(c echo.Context) error {
|
||||
}
|
||||
|
||||
repos, _ := a.client.Repositories(true)[namespace]
|
||||
|
||||
data := jet.VarMap{}
|
||||
data.Set("namespace", namespace)
|
||||
data.Set("namespaces", a.client.Namespaces())
|
||||
@@ -185,7 +202,8 @@ func (a *apiClient) viewTags(c echo.Context) error {
|
||||
data.Set("repo", repo)
|
||||
data.Set("tags", tags)
|
||||
data.Set("deleteAllowed", deleteAllowed)
|
||||
data.Set("events", registry.GetEvents(repo))
|
||||
repoPath, _ = url.PathUnescape(repoPath)
|
||||
data.Set("events", a.eventListener.GetEvents(repoPath))
|
||||
|
||||
return c.Render(http.StatusOK, "tags.html", data)
|
||||
}
|
||||
@@ -199,11 +217,35 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
|
||||
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
|
||||
}
|
||||
|
||||
// Retrieve full image info from various versions of manifests
|
||||
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
|
||||
if infoV1 == "" || infoV2 == "" {
|
||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo))
|
||||
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() {
|
||||
@@ -215,35 +257,50 @@ func (a *apiClient) viewTagInfo(c echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
var layersV2 []map[string]gjson.Result
|
||||
for _, s := range gjson.Get(infoV2, "layers").Array() {
|
||||
layersV2 = append(layersV2, s.Map())
|
||||
}
|
||||
|
||||
var layersV1 []map[string]interface{}
|
||||
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
|
||||
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
|
||||
// Sort key in the map to show the ordered on UI.
|
||||
m["ordered_keys"] = registry.SortedMapKeys(m)
|
||||
layersV1 = append(layersV1, m)
|
||||
}
|
||||
|
||||
// Count layers
|
||||
layersCount := len(layersV2)
|
||||
if layersCount == 0 {
|
||||
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
|
||||
}
|
||||
|
||||
// Gather sub-image info of multi-arch or cache image
|
||||
var digestList []map[string]interface{}
|
||||
for _, s := range manifests {
|
||||
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
|
||||
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
|
||||
// Sub-image of the specific arch.
|
||||
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
|
||||
var dSize int64
|
||||
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
|
||||
dSize = dSize + d.Int()
|
||||
}
|
||||
r["size"] = dSize
|
||||
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
|
||||
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
|
||||
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
|
||||
}
|
||||
} else {
|
||||
// Sub-image of the cache type.
|
||||
r["size"] = s.Get("size").Int()
|
||||
}
|
||||
r["ordered_keys"] = registry.SortedMapKeys(r)
|
||||
digestList = append(digestList, r)
|
||||
}
|
||||
|
||||
// Populate template vars
|
||||
data := jet.VarMap{}
|
||||
data.Set("namespace", namespace)
|
||||
data.Set("repo", repo)
|
||||
data.Set("tag", tag)
|
||||
data.Set("repoPath", repoPath)
|
||||
data.Set("sha256", sha256)
|
||||
data.Set("imageSize", imageSize)
|
||||
data.Set("tag", gjson.Get(infoV1, "tag").String())
|
||||
data.Set("repoPath", gjson.Get(infoV1, "name").String())
|
||||
data.Set("created", gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String())
|
||||
data.Set("created", created)
|
||||
data.Set("layersCount", layersCount)
|
||||
data.Set("layersV2", layersV2)
|
||||
data.Set("layersV1", layersV1)
|
||||
data.Set("isDigest", isDigest)
|
||||
data.Set("digestList", digestList)
|
||||
|
||||
return c.Render(http.StatusOK, "tag_info.html", data)
|
||||
}
|
||||
@@ -261,7 +318,7 @@ func (a *apiClient) deleteTag(c echo.Context) error {
|
||||
a.client.DeleteTag(repoPath, tag)
|
||||
}
|
||||
|
||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("/%s/%s", namespace, repo))
|
||||
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
|
||||
}
|
||||
|
||||
// checkDeletePermission check if tag deletion is allowed whether by anyone or permitted users.
|
||||
@@ -281,13 +338,18 @@ func (a *apiClient) checkDeletePermission(user string) bool {
|
||||
// viewLog view events from sqlite.
|
||||
func (a *apiClient) viewLog(c echo.Context) error {
|
||||
data := jet.VarMap{}
|
||||
data.Set("events", registry.GetEvents(""))
|
||||
data.Set("events", a.eventListener.GetEvents(""))
|
||||
|
||||
return c.Render(http.StatusOK, "event_log.html", data)
|
||||
}
|
||||
|
||||
// eventListener listen events from registry.
|
||||
func (a *apiClient) eventListener(c echo.Context) error {
|
||||
registry.ProcessEvents(c.Request(), a.config.EventRetentionDays)
|
||||
// receiveEvents receive events.
|
||||
func (a *apiClient) receiveEvents(c echo.Context) error {
|
||||
a.eventListener.ProcessEvents(c.Request())
|
||||
return c.String(http.StatusOK, "OK")
|
||||
}
|
||||
|
||||
// purgeOldTags purges old tags.
|
||||
func (a *apiClient) purgeOldTags(dryRun bool) {
|
||||
registry.PurgeOldTags(a.client, dryRun, a.config.PurgeTagsKeepDays, a.config.PurgeTagsKeepCount)
|
||||
}
|
||||
|
@@ -1,6 +1,7 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"regexp"
|
||||
@@ -9,11 +10,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/hhkbp2/go-logging"
|
||||
"github.com/parnurzeal/gorequest"
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const userAgent = "docker-registry-ui"
|
||||
|
||||
// Client main class.
|
||||
type Client struct {
|
||||
url string
|
||||
@@ -21,7 +24,7 @@ type Client struct {
|
||||
username string
|
||||
password string
|
||||
request *gorequest.SuperAgent
|
||||
logger logging.Logger
|
||||
logger *logrus.Entry
|
||||
mux sync.Mutex
|
||||
tokens map[string]string
|
||||
repos map[string][]string
|
||||
@@ -38,12 +41,13 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
|
||||
password: password,
|
||||
|
||||
request: gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !verifyTLS}),
|
||||
logger: setupLogging("registry.client"),
|
||||
logger: SetupLogging("registry.client"),
|
||||
tokens: map[string]string{},
|
||||
repos: map[string][]string{},
|
||||
tagCounts: map[string]int{},
|
||||
}
|
||||
resp, _, errs := c.request.Get(c.url+"/v2/").Set("User-Agent", "docker-registry-ui").End()
|
||||
resp, _, errs := c.request.Get(c.url+"/v2/").
|
||||
Set("User-Agent", userAgent).End()
|
||||
if len(errs) > 0 {
|
||||
c.logger.Error(errs[0])
|
||||
return nil
|
||||
@@ -69,7 +73,7 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
|
||||
c.logger.Warn("No token auth service discovered from ", c.url)
|
||||
return nil
|
||||
}
|
||||
} else if strings.HasPrefix(authHeader, "Basic") {
|
||||
} 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.")
|
||||
}
|
||||
@@ -81,14 +85,18 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
|
||||
func (c *Client) getToken(scope string) string {
|
||||
// Check if we have already a token and it's not expired.
|
||||
if token, ok := c.tokens[scope]; ok {
|
||||
resp, _, _ := c.request.Get(c.url+"/v2/").Set("Authorization", fmt.Sprintf("Bearer %s", token)).Set("User-Agent", "docker-registry-ui").End()
|
||||
resp, _, _ := c.request.Get(c.url+"/v2/").
|
||||
Set("Authorization", fmt.Sprintf("Bearer %s", token)).
|
||||
Set("User-Agent", userAgent).End()
|
||||
if resp != nil && resp.StatusCode == 200 {
|
||||
return token
|
||||
}
|
||||
}
|
||||
|
||||
request := gorequest.New().TLSClientConfig(&tls.Config{InsecureSkipVerify: !c.verifyTLS})
|
||||
resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).SetBasicAuth(c.username, c.password).Set("User-Agent", "docker-registry-ui").End()
|
||||
resp, data, errs := request.Get(fmt.Sprintf("%s&scope=%s", c.authURL, scope)).
|
||||
SetBasicAuth(c.username, c.password).
|
||||
Set("User-Agent", userAgent).End()
|
||||
if len(errs) > 0 {
|
||||
c.logger.Error(errs[0])
|
||||
return ""
|
||||
@@ -98,48 +106,51 @@ func (c *Client) getToken(scope string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
c.tokens[scope] = gjson.Get(data, "token").String()
|
||||
c.logger.Info("Received new token for scope ", scope)
|
||||
token := gjson.Get(data, "token").String()
|
||||
// Fix for docker_auth v1.5.0 only
|
||||
if token == "" {
|
||||
token = gjson.Get(data, "access_token").String()
|
||||
}
|
||||
|
||||
c.tokens[scope] = token
|
||||
c.logger.Debugf("Received new token for scope %s", scope)
|
||||
|
||||
return c.tokens[scope]
|
||||
}
|
||||
|
||||
// callRegistry make an HTTP request to Docker registry.
|
||||
func (c *Client) callRegistry(uri, scope string, manifest uint, delete bool) (rdata, rdigest string) {
|
||||
acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.manifest.v%d+json", manifest)
|
||||
// callRegistry make an HTTP request to retrieve data from Docker registry.
|
||||
func (c *Client) callRegistry(uri, scope, manifestFormat string) (string, gorequest.Response) {
|
||||
acceptHeader := fmt.Sprintf("application/vnd.docker.distribution.%s+json", manifestFormat)
|
||||
authHeader := ""
|
||||
if c.authURL != "" {
|
||||
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
|
||||
}
|
||||
|
||||
resp, data, errs := c.request.Get(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End()
|
||||
resp, data, errs := c.request.Get(c.url+uri).
|
||||
Set("Accept", acceptHeader).
|
||||
Set("Authorization", authHeader).
|
||||
Set("User-Agent", userAgent).End()
|
||||
if len(errs) > 0 {
|
||||
c.logger.Error(errs[0])
|
||||
return "", ""
|
||||
return "", resp
|
||||
}
|
||||
|
||||
c.logger.Info("GET ", uri, " ", resp.Status)
|
||||
c.logger.Debugf("GET %s %s", uri, resp.Status)
|
||||
// Returns 404 when no tags in the repo.
|
||||
if resp.StatusCode != 200 {
|
||||
return "", ""
|
||||
return "", resp
|
||||
}
|
||||
|
||||
// Ensure Docker-Content-Digest header is present as we use it in various places.
|
||||
// The header is probably in AWS ECR case.
|
||||
digest := resp.Header.Get("Docker-Content-Digest")
|
||||
|
||||
if delete {
|
||||
// Delete by manifest digest reference.
|
||||
parts := strings.Split(uri, "/manifests/")
|
||||
uri = parts[0] + "/manifests/" + digest
|
||||
resp, _, errs := c.request.Delete(c.url+uri).Set("Accept", acceptHeader).Set("Authorization", authHeader).Set("User-Agent", "docker-registry-ui").End()
|
||||
if len(errs) > 0 {
|
||||
c.logger.Error(errs[0])
|
||||
} else {
|
||||
// Returns 202 on success.
|
||||
c.logger.Info("DELETE ", uri, " (", parts[1], ") ", resp.Status)
|
||||
}
|
||||
return "", ""
|
||||
if digest == "" {
|
||||
// Try to get digest from body instead, should be equal to what would be presented in Docker-Content-Digest.
|
||||
h := crypto.SHA256.New()
|
||||
h.Write([]byte(data))
|
||||
resp.Header.Set("Docker-Content-Digest", fmt.Sprintf("sha256:%x", h.Sum(nil)))
|
||||
}
|
||||
|
||||
return data, digest
|
||||
return data, resp
|
||||
}
|
||||
|
||||
// Namespaces list repo namespaces.
|
||||
@@ -148,6 +159,9 @@ func (c *Client) Namespaces() []string {
|
||||
for k := range c.repos {
|
||||
namespaces = append(namespaces, k)
|
||||
}
|
||||
if !ItemInSlice("library", namespaces) {
|
||||
namespaces = append(namespaces, "library")
|
||||
}
|
||||
sort.Strings(namespaces)
|
||||
return namespaces
|
||||
}
|
||||
@@ -161,31 +175,48 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
|
||||
scope := "registry:catalog:*"
|
||||
data, _ := c.callRegistry("/v2/_catalog", scope, 2, false)
|
||||
if data == "" {
|
||||
return c.repos
|
||||
}
|
||||
|
||||
c.repos = map[string][]string{}
|
||||
for _, r := range gjson.Get(data, "repositories").Array() {
|
||||
namespace := "library"
|
||||
repo := r.String()
|
||||
if strings.Contains(repo, "/") {
|
||||
f := strings.SplitN(repo, "/", 2)
|
||||
namespace = f[0]
|
||||
repo = f[1]
|
||||
uri := "/v2/_catalog"
|
||||
tmp := map[string][]string{}
|
||||
for {
|
||||
data, resp := c.callRegistry(uri, scope, "manifest.v2")
|
||||
if data == "" {
|
||||
c.repos = tmp
|
||||
return c.repos
|
||||
}
|
||||
c.repos[namespace] = append(c.repos[namespace], repo)
|
||||
}
|
||||
|
||||
for _, r := range gjson.Get(data, "repositories").Array() {
|
||||
namespace := "library"
|
||||
repo := r.String()
|
||||
if strings.Contains(repo, "/") {
|
||||
f := strings.SplitN(repo, "/", 2)
|
||||
namespace = f[0]
|
||||
repo = f[1]
|
||||
}
|
||||
tmp[namespace] = append(tmp[namespace], repo)
|
||||
}
|
||||
|
||||
// pagination
|
||||
linkHeader := resp.Header.Get("Link")
|
||||
link := linkRegexp.FindStringSubmatch(linkHeader)
|
||||
if len(link) == 2 {
|
||||
// update uri and query next page
|
||||
uri = link[1]
|
||||
} else {
|
||||
// no more pages
|
||||
break
|
||||
}
|
||||
}
|
||||
c.repos = tmp
|
||||
return c.repos
|
||||
}
|
||||
|
||||
// Tags get tags for the repo.
|
||||
func (c *Client) Tags(repo string) []string {
|
||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
||||
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, 2, false)
|
||||
data, _ := c.callRegistry(fmt.Sprintf("/v2/%s/tags/list", repo), scope, "manifest.v2")
|
||||
var tags []string
|
||||
for _, t := range gjson.Get(data, "tags").Array() {
|
||||
tags = append(tags, t.String())
|
||||
@@ -193,24 +224,45 @@ func (c *Client) Tags(repo string) []string {
|
||||
return tags
|
||||
}
|
||||
|
||||
// TagInfo get image info for the repo tag.
|
||||
func (c *Client) TagInfo(repo, tag string, v1only bool) (rsha256, rinfoV1, rinfoV2 string) {
|
||||
// ManifestList gets manifest list entries for a tag for the repo.
|
||||
func (c *Client) ManifestList(repo, tag string) (string, []gjson.Result) {
|
||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
||||
infoV1, _ := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 1, false)
|
||||
if infoV1 == "" {
|
||||
return "", "", ""
|
||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
|
||||
// If manifest.list.v2 does not exist because it's a normal image,
|
||||
// the registry returns manifest.v1 or manifest.v2 if requested by sha256.
|
||||
info, resp := c.callRegistry(uri, scope, "manifest.list.v2")
|
||||
digest := resp.Header.Get("Docker-Content-Digest")
|
||||
sha256 := ""
|
||||
if digest != "" {
|
||||
sha256 = digest[7:]
|
||||
}
|
||||
c.logger.Debugf(`Received manifest.list.v2 with sha256 "%s" from %s: %s`, sha256, uri, info)
|
||||
return sha256, gjson.Get(info, "manifests").Array()
|
||||
}
|
||||
|
||||
if v1only {
|
||||
// TagInfo get image info for the repo tag or digest sha256.
|
||||
func (c *Client) TagInfo(repo, tag string, v1only bool) (string, string, string) {
|
||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, tag)
|
||||
// Note, if manifest.v1 does not exist because the image is requested by sha256,
|
||||
// the registry returns manifest.v2 instead or manifest.list.v2 if it's the manifest list!
|
||||
infoV1, _ := c.callRegistry(uri, scope, "manifest.v1")
|
||||
c.logger.Debugf("Received manifest.v1 from %s: %s", uri, infoV1)
|
||||
if infoV1 == "" || v1only {
|
||||
return "", infoV1, ""
|
||||
}
|
||||
|
||||
infoV2, digest := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, false)
|
||||
// Note, if manifest.v2 does not exist because the image is in the older format (Docker 1.9),
|
||||
// the registry returns manifest.v1 instead or manifest.list.v2 if it's the manifest list requested by sha256!
|
||||
infoV2, resp := c.callRegistry(uri, scope, "manifest.v2")
|
||||
c.logger.Debugf("Received manifest.v2 from %s: %s", uri, infoV2)
|
||||
digest := resp.Header.Get("Docker-Content-Digest")
|
||||
if infoV2 == "" || digest == "" {
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
sha256 := digest[7:]
|
||||
c.logger.Debugf("sha256 for %s/%s is %s", repo, tag, sha256)
|
||||
return sha256, infoV1, infoV2
|
||||
}
|
||||
|
||||
@@ -222,7 +274,8 @@ func (c *Client) TagCounts() map[string]int {
|
||||
// CountTags count repository tags in background regularly.
|
||||
func (c *Client) CountTags(interval uint8) {
|
||||
for {
|
||||
c.logger.Info("Calculating tags in background...")
|
||||
start := time.Now()
|
||||
c.logger.Info("[CountTags] Calculating image tags...")
|
||||
catalog := c.Repositories(false)
|
||||
for n, repos := range catalog {
|
||||
for _, r := range repos {
|
||||
@@ -233,7 +286,7 @@ func (c *Client) CountTags(interval uint8) {
|
||||
c.tagCounts[fmt.Sprintf("%s/%s", n, r)] = len(c.Tags(repoPath))
|
||||
}
|
||||
}
|
||||
c.logger.Info("Tags calculation complete.")
|
||||
c.logger.Infof("[CountTags] Job complete (%v).", time.Now().Sub(start))
|
||||
time.Sleep(time.Duration(interval) * time.Minute)
|
||||
}
|
||||
}
|
||||
@@ -241,5 +294,31 @@ func (c *Client) CountTags(interval uint8) {
|
||||
// DeleteTag delete image tag.
|
||||
func (c *Client) DeleteTag(repo, tag string) {
|
||||
scope := fmt.Sprintf("repository:%s:*", repo)
|
||||
c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, true)
|
||||
// Get sha256 digest for tag.
|
||||
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2")
|
||||
|
||||
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" {
|
||||
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2")
|
||||
}
|
||||
|
||||
// Delete by manifest digest reference.
|
||||
authHeader := ""
|
||||
if c.authURL != "" {
|
||||
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
|
||||
}
|
||||
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest"))
|
||||
resp, _, errs := c.request.Delete(c.url+uri).
|
||||
Set("Authorization", authHeader).
|
||||
Set("User-Agent", userAgent).End()
|
||||
if len(errs) > 0 {
|
||||
c.logger.Error(errs[0])
|
||||
} else {
|
||||
// Returns 202 on success.
|
||||
if !strings.Contains(repo, "/") {
|
||||
c.tagCounts["library/"+repo]--
|
||||
} else {
|
||||
c.tagCounts[repo]--
|
||||
}
|
||||
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)
|
||||
}
|
||||
}
|
||||
|
@@ -2,23 +2,24 @@ package registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/hhkbp2/go-logging"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// setupLogging configure logging.
|
||||
func setupLogging(name string) logging.Logger {
|
||||
logger := logging.GetLogger(name)
|
||||
handler := logging.NewStdoutHandler()
|
||||
format := "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
dateFormat := "%Y-%m-%d %H:%M:%S"
|
||||
formatter := logging.NewStandardFormatter(format, dateFormat)
|
||||
handler.SetFormatter(formatter)
|
||||
logger.SetLevel(logging.LevelInfo)
|
||||
logger.AddHandler(handler)
|
||||
return logger
|
||||
// SetupLogging setup logger
|
||||
func SetupLogging(name string) *logrus.Entry {
|
||||
logrus.SetFormatter(&logrus.TextFormatter{
|
||||
TimestampFormat: time.RFC3339,
|
||||
FullTimestamp: true,
|
||||
})
|
||||
// Output to stdout instead of the default stderr.
|
||||
logrus.SetOutput(os.Stdout)
|
||||
|
||||
return logrus.WithFields(logrus.Fields{"logger": name})
|
||||
}
|
||||
|
||||
// SortedMapKeys sort keys of the map where values can be of any type.
|
||||
@@ -40,5 +41,20 @@ func PrettySize(size float64) string {
|
||||
size = size / 1024
|
||||
i = i + 1
|
||||
}
|
||||
return fmt.Sprintf("%.*f %s", 0, size, units[i])
|
||||
// Format decimals as follow: 0 B, 0 KB, 0.0 MB, 0.00 GB
|
||||
decimals := i - 1
|
||||
if decimals < 0 {
|
||||
decimals = 0
|
||||
}
|
||||
return fmt.Sprintf("%.*f %s", decimals, size, units[i])
|
||||
}
|
||||
|
||||
// ItemInSlice check if item is an element of slice
|
||||
func ItemInSlice(item string, slice []string) bool {
|
||||
for _, i := range slice {
|
||||
if i == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
@@ -14,14 +14,14 @@ func TestSortedMapKeys(t *testing.T) {
|
||||
"zoo": "bar",
|
||||
}
|
||||
b := map[string]timeSlice{
|
||||
"zoo": []tagData{tagData{name: "1", created: time.Now()}},
|
||||
"abc": []tagData{tagData{name: "1", created: time.Now()}},
|
||||
"foo": []tagData{tagData{name: "1", created: time.Now()}},
|
||||
"zoo": []tagData{{name: "1", created: time.Now()}},
|
||||
"abc": []tagData{{name: "1", created: time.Now()}},
|
||||
"foo": []tagData{{name: "1", created: time.Now()}},
|
||||
}
|
||||
c := map[string][]string{
|
||||
"zoo": []string{"1", "2"},
|
||||
"foo": []string{"1", "2"},
|
||||
"abc": []string{"1", "2"},
|
||||
"zoo": {"1", "2"},
|
||||
"foo": {"1", "2"},
|
||||
"abc": {"1", "2"},
|
||||
}
|
||||
expect := []string{"abc", "foo", "zoo"}
|
||||
convey.Convey("Sort map keys", t, func() {
|
||||
@@ -37,11 +37,21 @@ func TestPrettySize(t *testing.T) {
|
||||
123: "123 B",
|
||||
23123: "23 KB",
|
||||
23923: "23 KB",
|
||||
723425120: "690 MB",
|
||||
8534241213: "8 GB",
|
||||
723425120: "689.9 MB",
|
||||
8534241213: "7.95 GB",
|
||||
}
|
||||
for key, val := range input {
|
||||
convey.So(PrettySize(key), convey.ShouldEqual, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestItemInSlice(t *testing.T) {
|
||||
a := []string{"abc", "def", "ghi"}
|
||||
convey.Convey("Check whether element is in slice", t, func() {
|
||||
convey.So(ItemInSlice("abc", a), convey.ShouldBeTrue)
|
||||
convey.So(ItemInSlice("ghi", a), convey.ShouldBeTrue)
|
||||
convey.So(ItemInSlice("abc1", a), convey.ShouldBeFalse)
|
||||
convey.So(ItemInSlice("gh", a), convey.ShouldBeFalse)
|
||||
})
|
||||
}
|
||||
|
@@ -1,132 +0,0 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana/pkg/cmd/grafana-cli/logger"
|
||||
// 🐒 patching of "database/sql".
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
dbFile = "data/registry_events.db"
|
||||
schema = `
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action CHAR(4) NULL,
|
||||
repository VARCHAR(100) NULL,
|
||||
tag VARCHAR(100) NULL,
|
||||
ip VARCHAR(15) NULL,
|
||||
user VARCHAR(50) NULL,
|
||||
created DATETIME NULL
|
||||
);
|
||||
`
|
||||
)
|
||||
|
||||
type eventData struct {
|
||||
Events []interface{} `json:"events"`
|
||||
}
|
||||
|
||||
// EventRow event row from sqlite
|
||||
type EventRow struct {
|
||||
ID int
|
||||
Action string
|
||||
Repository string
|
||||
Tag string
|
||||
IP string
|
||||
User string
|
||||
Created time.Time
|
||||
}
|
||||
|
||||
// ProcessEvents parse and store registry events
|
||||
func ProcessEvents(request *http.Request, retention int) {
|
||||
logger := setupLogging("registry.event_listener")
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
var t eventData
|
||||
if err := decoder.Decode(&t); err != nil {
|
||||
logger.Errorf("Problem decoding event from request: %+v", request)
|
||||
return
|
||||
}
|
||||
logger.Debugf("Received event: %+v", t)
|
||||
j, _ := json.Marshal(t)
|
||||
|
||||
db, err := sql.Open("sqlite3", dbFile)
|
||||
if err != nil {
|
||||
logger.Error("Error opening sqlite db: ", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec(schema)
|
||||
if err != nil {
|
||||
logger.Error("Error creating a table: ", err)
|
||||
return
|
||||
}
|
||||
|
||||
stmt, _ := db.Prepare("INSERT INTO events(action, repository, tag, ip, user, created) values(?,?,?,?,?,DateTime('now'))")
|
||||
for _, e := range gjson.GetBytes(j, "events").Array() {
|
||||
// Ignore calls by docker-registry-ui itself.
|
||||
if e.Get("request.useragent").String() == "docker-registry-ui" {
|
||||
continue
|
||||
}
|
||||
action := e.Get("action").String()
|
||||
repository := e.Get("target.repository").String()
|
||||
tag := e.Get("target.tag").String()
|
||||
// Tag is empty in case of signed pull.
|
||||
if tag == "" {
|
||||
tag = e.Get("target.digest").String()
|
||||
}
|
||||
ip := strings.Split(e.Get("request.addr").String(), ":")[0]
|
||||
user := e.Get("actor.name").String()
|
||||
logger.Debugf("Parsed event data: %s %s:%s %s %s ", action, repository, tag, ip, user)
|
||||
|
||||
res, err := stmt.Exec(action, repository, tag, ip, user)
|
||||
if err != nil {
|
||||
logger.Error("Error inserting a row: ", err)
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
logger.Debug("New event added with id ", id)
|
||||
}
|
||||
|
||||
// Purge old records.
|
||||
stmt, _ = db.Prepare("DELETE FROM events WHERE created < DateTime('now',?)")
|
||||
res, _ := stmt.Exec(fmt.Sprintf("-%d day", retention))
|
||||
count, _ := res.RowsAffected()
|
||||
logger.Debug("Rows deleted: ", count)
|
||||
}
|
||||
|
||||
// GetEvents retrieve events from sqlite db
|
||||
func GetEvents(repository string) []EventRow {
|
||||
var events []EventRow
|
||||
db, err := sql.Open("sqlite3", dbFile)
|
||||
if err != nil {
|
||||
logger.Error("Error opening sqlite db: ", err)
|
||||
return events
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
query := "SELECT * FROM events ORDER BY id DESC LIMIT 1000"
|
||||
if repository != "" {
|
||||
query = fmt.Sprintf("SELECT * FROM events WHERE repository='%s' ORDER BY id DESC LIMIT 5", repository)
|
||||
}
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
logger.Error("Error selecting from table: ", err)
|
||||
return events
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var row EventRow
|
||||
rows.Scan(&row.ID, &row.Action, &row.Repository, &row.Tag, &row.IP, &row.User, &row.Created)
|
||||
events = append(events, row)
|
||||
}
|
||||
rows.Close()
|
||||
return events
|
||||
}
|
@@ -5,7 +5,6 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/hhkbp2/go-logging"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -34,10 +33,7 @@ func (p timeSlice) Swap(i, j int) {
|
||||
|
||||
// PurgeOldTags purge old tags.
|
||||
func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) {
|
||||
logger := setupLogging("registry.tasks.PurgeOldTags")
|
||||
// Reduce client logging.
|
||||
client.logger.SetLevel(logging.LevelError)
|
||||
|
||||
logger := SetupLogging("registry.tasks.PurgeOldTags")
|
||||
dryRunText := ""
|
||||
if purgeDryRun {
|
||||
logger.Warn("Dry-run mode enabled.")
|
||||
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 186 KiB |
Before Width: | Height: | Size: 334 KiB After Width: | Height: | Size: 174 KiB |
BIN
screenshots/3.png
Normal file
After Width: | Height: | Size: 265 KiB |
BIN
screenshots/4.png
Normal file
After Width: | Height: | Size: 290 KiB |
BIN
static/Bootstrap-3.3.5/fonts/glyphicons-halflings-regular.ttf
Normal file
BIN
static/Bootstrap-3.3.5/fonts/glyphicons-halflings-regular.woff
Normal file
BIN
static/Bootstrap-3.3.5/fonts/glyphicons-halflings-regular.woff2
Normal file
4
static/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
https://cdn.datatables.net/r/bs-3.3.5/jq-2.1.4,dt-1.10.8/datatables.min.css
|
||||
https://cdn.datatables.net/r/bs-3.3.5/jqc-1.11.3,dt-1.10.8/datatables.min.js
|
||||
https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js
|
||||
|
0
static/bootstrap-confirmation.min.js
vendored
Executable file → Normal file
21
static/datatables.min.css
vendored
Normal file
204
static/datatables.min.js
vendored
Normal file
BIN
static/favicon.ico
Normal file
After Width: | Height: | Size: 1.1 KiB |
128
static/sorting_natural.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Data can often be a complicated mix of numbers and letters (file names
|
||||
* are a common example) and sorting them in a natural manner is quite a
|
||||
* difficult problem.
|
||||
*
|
||||
* Fortunately a deal of work has already been done in this area by other
|
||||
* authors - the following plug-in uses the [naturalSort() function by Jim
|
||||
* Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support) to provide natural sorting in DataTables.
|
||||
*
|
||||
* @name Natural sorting
|
||||
* @summary Sort data with a mix of numbers and letters _naturally_.
|
||||
* @author [Jim Palmer](http://www.overset.com/2008/09/01/javascript-natural-sort-algorithm-with-unicode-support)
|
||||
* @author [Michael Buehler] (https://github.com/AnimusMachina)
|
||||
*
|
||||
* @example
|
||||
* $('#example').dataTable( {
|
||||
* columnDefs: [
|
||||
* { type: 'natural', targets: 0 }
|
||||
* ]
|
||||
* } );
|
||||
*
|
||||
* Html can be stripped from sorting by using 'natural-nohtml' such as
|
||||
*
|
||||
* $('#example').dataTable( {
|
||||
* columnDefs: [
|
||||
* { type: 'natural-nohtml', targets: 0 }
|
||||
* ]
|
||||
* } );
|
||||
*
|
||||
*/
|
||||
|
||||
(function() {
|
||||
|
||||
/*
|
||||
* Natural Sort algorithm for Javascript - Version 0.7 - Released under MIT license
|
||||
* Author: Jim Palmer (based on chunking idea from Dave Koelle)
|
||||
* Contributors: Mike Grier (mgrier.com), Clint Priest, Kyle Adams, guillermo
|
||||
* See: http://js-naturalsort.googlecode.com/svn/trunk/naturalSort.js
|
||||
*/
|
||||
function naturalSort (a, b, html) {
|
||||
var re = /(^-?[0-9]+(\.?[0-9]*)[df]?e?[0-9]?%?$|^0x[0-9a-f]+$|[0-9]+)/gi,
|
||||
sre = /(^[ ]*|[ ]*$)/g,
|
||||
dre = /(^([\w ]+,?[\w ]+)?[\w ]+,?[\w ]+\d+:\d+(:\d+)?[\w ]?|^\d{1,4}[\/\-]\d{1,4}[\/\-]\d{1,4}|^\w+, \w+ \d+, \d{4})/,
|
||||
hre = /^0x[0-9a-f]+$/i,
|
||||
ore = /^0/,
|
||||
htmre = /(<([^>]+)>)/ig,
|
||||
// convert all to strings and trim()
|
||||
x = a.toString().replace(sre, '') || '',
|
||||
y = b.toString().replace(sre, '') || '';
|
||||
// remove html from strings if desired
|
||||
if (!html) {
|
||||
x = x.replace(htmre, '');
|
||||
y = y.replace(htmre, '');
|
||||
}
|
||||
// chunk/tokenize
|
||||
var xN = x.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
|
||||
yN = y.replace(re, '\0$1\0').replace(/\0$/,'').replace(/^\0/,'').split('\0'),
|
||||
// numeric, hex or date detection
|
||||
xD = parseInt(x.match(hre), 10) || (xN.length !== 1 && x.match(dre) && Date.parse(x)),
|
||||
yD = parseInt(y.match(hre), 10) || xD && y.match(dre) && Date.parse(y) || null;
|
||||
|
||||
// first try and sort Hex codes or Dates
|
||||
if (yD) {
|
||||
if ( xD < yD ) {
|
||||
return -1;
|
||||
}
|
||||
else if ( xD > yD ) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// natural sorting through split numeric strings and default strings
|
||||
for(var cLoc=0, numS=Math.max(xN.length, yN.length); cLoc < numS; cLoc++) {
|
||||
// find floats not starting with '0', string or 0 if not defined (Clint Priest)
|
||||
var oFxNcL = !(xN[cLoc] || '').match(ore) && parseFloat(xN[cLoc], 10) || xN[cLoc] || 0;
|
||||
var oFyNcL = !(yN[cLoc] || '').match(ore) && parseFloat(yN[cLoc], 10) || yN[cLoc] || 0;
|
||||
// handle numeric vs string comparison - number < string - (Kyle Adams)
|
||||
if (isNaN(oFxNcL) !== isNaN(oFyNcL)) {
|
||||
return (isNaN(oFxNcL)) ? 1 : -1;
|
||||
}
|
||||
// rely on string comparison if different types - i.e. '02' < 2 != '02' < '2'
|
||||
else if (typeof oFxNcL !== typeof oFyNcL) {
|
||||
oFxNcL += '';
|
||||
oFyNcL += '';
|
||||
}
|
||||
if (oFxNcL < oFyNcL) {
|
||||
return -1;
|
||||
}
|
||||
if (oFxNcL > oFyNcL) {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
jQuery.extend( jQuery.fn.dataTableExt.oSort, {
|
||||
"natural-asc": function ( a, b ) {
|
||||
return naturalSort(a,b,true);
|
||||
},
|
||||
|
||||
"natural-desc": function ( a, b ) {
|
||||
return naturalSort(a,b,true) * -1;
|
||||
},
|
||||
|
||||
"natural-nohtml-asc": function( a, b ) {
|
||||
return naturalSort(a,b,false);
|
||||
},
|
||||
|
||||
"natural-nohtml-desc": function( a, b ) {
|
||||
return naturalSort(a,b,false) * -1;
|
||||
},
|
||||
|
||||
"natural-ci-asc": function( a, b ) {
|
||||
a = a.toString().toLowerCase();
|
||||
b = b.toString().toLowerCase();
|
||||
|
||||
return naturalSort(a,b,true);
|
||||
},
|
||||
|
||||
"natural-ci-desc": function( a, b ) {
|
||||
a = a.toString().toLowerCase();
|
||||
b = b.toString().toLowerCase();
|
||||
|
||||
return naturalSort(a,b,true) * -1;
|
||||
}
|
||||
} );
|
||||
|
||||
}());
|
76
template.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/CloudyKit/jet"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/quiq/docker-registry-ui/registry"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Template Jet template.
|
||||
type Template struct {
|
||||
View *jet.Set
|
||||
}
|
||||
|
||||
// Render render template.
|
||||
func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
t, err := r.View.GetTemplate(name)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Fatal error template file: %s", err))
|
||||
}
|
||||
vars, ok := data.(jet.VarMap)
|
||||
if !ok {
|
||||
vars = jet.VarMap{}
|
||||
}
|
||||
err = t.Execute(w, vars, nil)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupRenderer template engine init.
|
||||
func setupRenderer(debug bool, registryHost, basePath string) *Template {
|
||||
view := jet.NewHTMLSet("templates")
|
||||
view.SetDevelopmentMode(debug)
|
||||
|
||||
view.AddGlobal("version", version)
|
||||
view.AddGlobal("basePath", basePath)
|
||||
view.AddGlobal("registryHost", registryHost)
|
||||
view.AddGlobal("pretty_size", func(size interface{}) string {
|
||||
var value float64
|
||||
switch i := size.(type) {
|
||||
case gjson.Result:
|
||||
value = float64(i.Int())
|
||||
case int64:
|
||||
value = float64(i)
|
||||
}
|
||||
return registry.PrettySize(value)
|
||||
})
|
||||
view.AddGlobal("pretty_time", func(datetime interface{}) string {
|
||||
d := strings.Replace(datetime.(string), "T", " ", 1)
|
||||
d = strings.Replace(d, "Z", "", 1)
|
||||
return strings.Split(d, ".")[0]
|
||||
})
|
||||
view.AddGlobal("parse_map", func(m interface{}) string {
|
||||
var res string
|
||||
for _, k := range registry.SortedMapKeys(m) {
|
||||
res = res + fmt.Sprintf(`<tr><td style="padding: 0 10px; width: 20%%">%s</td><td style="padding: 0 10px">%v</td></tr>`, k, m.(map[string]interface{})[k])
|
||||
}
|
||||
return res
|
||||
})
|
||||
view.AddGlobal("url_decode", func(m interface{}) string {
|
||||
res, err := url.PathUnescape(m.(string))
|
||||
if err != nil {
|
||||
return m.(string)
|
||||
}
|
||||
return res
|
||||
})
|
||||
|
||||
return &Template{View: view}
|
||||
}
|
@@ -5,25 +5,25 @@
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Docker Registry UI</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/r/bs-3.3.5/jq-2.1.4,dt-1.10.8/datatables.min.css" />
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/r/bs-3.3.5/jqc-1.11.3,dt-1.10.8/datatables.min.js"></script>
|
||||
<link rel="stylesheet" type="text/css" href="{{ basePath }}/static/datatables.min.css"/>
|
||||
<script type="text/javascript" src="{{ basePath }}/static/datatables.min.js"></script>
|
||||
{{yield head()}}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div style="float: left">
|
||||
<h2>Docker Registry UI</h2>
|
||||
<h2><a href="{{ basePath }}/" style="text-decoration: none">Docker Registry UI</a></h2>
|
||||
</div>
|
||||
<div style="float: right">
|
||||
<a href="/events">Event Log</a>
|
||||
<h4><a href="{{ basePath }}/events">Event Log</a></h4>
|
||||
</div>
|
||||
<div style="clear: both"></div>
|
||||
|
||||
{{yield body()}}
|
||||
|
||||
<div style="padding: 10px 0; margin-bottom: 20px">
|
||||
<div style="float: left">
|
||||
© 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
|
||||
<div style="text-align: center; color:darkgrey">
|
||||
Docker Registry UI v{{version}} © 2017-2022 <a href="https://quiq.com">Quiq Inc.</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -6,7 +6,10 @@
|
||||
$('#datatable').DataTable({
|
||||
"pageLength": 10,
|
||||
"order": [[ 4, 'desc' ]],
|
||||
"stateSave": true
|
||||
"stateSave": true,
|
||||
"language": {
|
||||
"emptyTable": "No events."
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -14,7 +17,6 @@
|
||||
|
||||
{{block body()}}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li class="active">Event Log</li>
|
||||
</ol>
|
||||
|
||||
@@ -39,7 +41,7 @@
|
||||
{{end}}
|
||||
<td>{{ e.IP }}</td>
|
||||
<td>{{ e.User }}</td>
|
||||
<td>{{ e.Created }}</td>
|
||||
<td>{{ e.Created|pretty_time }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
@@ -3,19 +3,25 @@
|
||||
{{block head()}}
|
||||
<script type="text/javascript">
|
||||
$(document).ready(function() {
|
||||
$('#datatable').DataTable({
|
||||
"pageLength": 25,
|
||||
"stateSave": true
|
||||
});
|
||||
$('#namespace').on('change', function (e) {
|
||||
window.location = '/' + this.value;
|
||||
window.location = '{{ basePath }}/' + this.value;
|
||||
});
|
||||
if (window.location.pathname == '/') {
|
||||
namespace = window.location.pathname;
|
||||
namespace = namespace.replace("{{ basePath }}", "");
|
||||
if (namespace == '/') {
|
||||
namespace = 'library';
|
||||
} else {
|
||||
namespace = window.location.pathname.split('/')[1]
|
||||
namespace = namespace.split('/')[1]
|
||||
}
|
||||
$('#namespace').val(namespace);
|
||||
|
||||
$('#datatable').DataTable({
|
||||
"pageLength": 25,
|
||||
"stateSave": true,
|
||||
"language": {
|
||||
"emptyTable": "No repositories in \"" + namespace + "\" namespace."
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
@@ -23,15 +29,22 @@
|
||||
{{block body()}}
|
||||
<div style="float: right">
|
||||
<select id="namespace" class="form-control input-sm" style="height: 36px">
|
||||
<option value="" disabled>-- Namespace --</option>
|
||||
{{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="/">Home</a></li>
|
||||
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
|
||||
{{if namespace != "library"}}
|
||||
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
|
||||
{{end}}
|
||||
</ol>
|
||||
|
||||
<table id="datatable" class="table table-striped table-bordered">
|
||||
@@ -43,10 +56,12 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range repo := repos}}
|
||||
{{if !isset(tagCounts[namespace+"/"+repo]) || (isset(tagCounts[namespace+"/"+repo]) && tagCounts[namespace+"/"+repo] > 0)}}
|
||||
<tr>
|
||||
<td><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></td>
|
||||
<td><a href="{{ basePath }}/{{ namespace }}/{{ repo|url }}">{{ repo }}</a></td>
|
||||
<td>{{ tagCounts[namespace+"/"+repo] }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
@@ -4,38 +4,79 @@
|
||||
|
||||
{{block body()}}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
|
||||
{{if namespace != "library"}}
|
||||
<li><a href="/{{ namespace }}">{{ namespace }}</a></li>
|
||||
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
|
||||
{{end}}
|
||||
<li><a href="/{{ namespace }}/{{ repo }}">{{ repo }}</a></li>
|
||||
<li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li>
|
||||
<li class="active">{{ tag }}</li>
|
||||
</ol>
|
||||
|
||||
<h4>Image Details</h4>
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead bgcolor="#ddd">
|
||||
<tr>
|
||||
<th colspan="2">Image Details</th>
|
||||
<th colspan="2">Summary</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tr>
|
||||
<td width="20%">Image</td><td>{{ registryHost }}/{{ repoPath }}:{{ tag }}</td>
|
||||
<td width="20%"><b>Image URL</b></td><td>{{ registryHost }}/{{ repoPath }}{{ isDigest ? "@" : ":" }}{{ tag }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>sha256</td><td>{{ sha256 }}</td>
|
||||
<td><b>Digest</b></td><td>sha256:{{ sha256 }}</td>
|
||||
</tr>
|
||||
{{if created}}
|
||||
<tr>
|
||||
<td><b>Created On</b></td><td>{{ created|pretty_time }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
{{if not digestList}}
|
||||
<tr>
|
||||
<td><b>Image Size</b></td><td>{{ imageSize|pretty_size }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Created On</td><td>{{ created|pretty_time }}</td>
|
||||
<td><b>Layer Count</b></td><td>{{ layersCount }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
<tr>
|
||||
<td>Image Size</td><td>{{ imageSize|pretty_size }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Layer Count</td><td>{{ layersCount }}</td>
|
||||
<td><b>Manifest Formats</b></td>
|
||||
<td>{{if not isDigest}}Manifest v2 schema 1{{else}}<font color="#c2c2c2">Manifest v2 schema 1</font>{{end}} |
|
||||
{{if not digestList && layersV2}}Manifest v2 schema 2{{else}}<font color="#c2c2c2">Manifest v2 schema 2</font>{{end}} |
|
||||
{{if digestList}}Manifest List v2 schema 2{{else}}<font color="#c2c2c2">Manifest List v2 schema 2</font>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{{if layersV2}}
|
||||
<h4>Manifest v2</h4>
|
||||
{{if digestList}}
|
||||
<h4>Sub-images <!-- Manifest List v2 schema 2: multi-arch or cache image --></h4>
|
||||
{{range index, manifest := digestList}}
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead bgcolor="#ddd">
|
||||
<tr>
|
||||
<th colspan="2">Manifest #{{ index+1 }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{{range key := manifest["ordered_keys"]}}
|
||||
<tr>
|
||||
<td width="20%">{{ key }}</td>
|
||||
{{if key == "platform" || key == "annotations"}}
|
||||
<td style="padding: 0">
|
||||
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
|
||||
<!-- Nested range does not work. Iterating via filter over the map. -->
|
||||
{{ manifest[key]|parse_map|raw }}
|
||||
</table>
|
||||
</td>
|
||||
{{else if key == "size"}}
|
||||
<td>{{ manifest[key]|pretty_size }}</td>
|
||||
{{else}}
|
||||
<td>{{ manifest[key]|raw }}</td>
|
||||
{{end}}
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
{{else if layersV2}}
|
||||
<h4>Blobs <!-- Manifest v2 schema 2--></h4>
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead bgcolor="#ddd">
|
||||
<tr>
|
||||
@@ -54,7 +95,8 @@
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
<h4>Manifest v1</h4>
|
||||
{{if not isDigest}}
|
||||
<h4>Image History <!-- Manifest v2 schema 1--></h4>
|
||||
{{range index, layer := layersV1}}
|
||||
<table class="table table-striped table-bordered">
|
||||
<thead bgcolor="#ddd">
|
||||
@@ -81,5 +123,6 @@
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{end}}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
{{extends "base.html"}}
|
||||
|
||||
{{block head()}}
|
||||
<script type="text/javascript" src="/static/bootstrap-confirmation.min.js"></script>
|
||||
<script type="text/javascript" src="https://cdn.datatables.net/plug-ins/1.10.16/sorting/natural.js"></script>
|
||||
<script type="text/javascript" src="{{ basePath }}/static/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({
|
||||
@@ -11,7 +11,10 @@
|
||||
"stateSave": true,
|
||||
columnDefs: [
|
||||
{ type: 'natural', targets: 0 }
|
||||
]
|
||||
],
|
||||
"language": {
|
||||
"emptyTable": "No tags in this repository."
|
||||
}
|
||||
})
|
||||
function populateConfirmation() {
|
||||
$('[data-toggle=confirmation]').confirmation({
|
||||
@@ -29,11 +32,11 @@
|
||||
|
||||
{{block body()}}
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="/">Home</a></li>
|
||||
<li><a href="{{ basePath }}/">{{ registryHost }}</a></li>
|
||||
{{if namespace != "library"}}
|
||||
<li><a href="/{{ namespace }}">{{ namespace }}</a></li>
|
||||
<li><a href="{{ basePath }}/{{ namespace }}">{{ namespace }}</a></li>
|
||||
{{end}}
|
||||
<li class="active">{{ repo }}</li>
|
||||
<li class="active">{{ repo|url_decode }}</li>
|
||||
</ol>
|
||||
|
||||
<table id="datatable" class="table table-striped table-bordered">
|
||||
@@ -46,9 +49,9 @@
|
||||
{{range tag := tags}}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a>
|
||||
<a href="{{ basePath }}/{{ namespace }}/{{ repo }}/{{ tag }}">{{ tag }}</a>
|
||||
{{if deleteAllowed}}
|
||||
<a href="/{{ namespace }}/{{ repo }}/{{ tag }}/delete" data-toggle="confirmation" class="btn btn-danger btn-xs pull-right" role="button">Delete</a>
|
||||
<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>
|
||||
@@ -78,7 +81,7 @@
|
||||
{{end}}
|
||||
<td>{{ e.IP }}</td>
|
||||
<td>{{ e.User }}</td>
|
||||
<td>{{ e.Created }}</td>
|
||||
<td>{{ e.Created|pretty_time }}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
3
version.go
Normal file
@@ -0,0 +1,3 @@
|
||||
package main
|
||||
|
||||
const version = "0.9.4"
|