26 Commits
0.8.2 ... 0.9.6

Author SHA1 Message Date
Roman Vynar
dc1caa7451 Upgrade Go version to 1.20.2, alpine to 3.17 and other dependencies 2023-03-30 15:47:06 +03:00
Roman Vynar
901beb5c9b Fix dep version github.com/mattn/go-sqlite3 2022-09-08 14:34:52 +03:00
Roman Vynar
102e9ae154 Release version to 0.9.5 2022-09-05 13:05:58 +03:00
Roman Vynar
e538d4c3f1 Implement purge_tags_keep_from_file option. Refactoring. (#63)
* Implement purge_tags_keep_from_file option.
* main.go refactoring.
* Refactor user permissions.
* Update changelog.
2022-09-02 18:05:45 +03:00
Rob Best
b5e11aae10 Add anyone_can_view_events option (#59)
Makes it possible to restrict access to the event log to the configured
list of admins.
2022-09-02 16:52:59 +03:00
Dieter Maes
2aa58fc9ba reverse sort tags on name if equal dates (#62) 2022-09-01 18:13:22 +03:00
Roman Vynar
ad41aca4e5 Upgrade go version and dependencies 2022-08-29 18:57:08 +03:00
Dieter Maes
76f380d3c2 Keep tags based on regex (#61)
* Ignore tags from purging by regexp
2022-04-08 17:21:36 +03:00
Roman Vynar
31b16bb17a Merge branch 'master' of github.com:Quiq/docker-registry-ui 2022-04-06 16:48:04 +03:00
Roman Vynar
a1bef247e0 Bump version, upgrade dependencies 2022-04-06 16:47:52 +03:00
jonny08152
905e8f793b use temporary map to prevent UI disruption during tags calculation (#60) 2021-12-22 11:07:31 +02:00
Roman Vynar
19d15065ed Bump version 2021-04-26 16:41:14 +03:00
Roman Vynar
811c9d7da0 Update dependencies 2021-04-26 16:36:18 +03:00
Steven Beverly
a84e06443a Support deleting manifest.list.v2 (#55)
Co-authored-by: Steven Beverly <steven@rigel.gopes>
2021-04-26 15:48:55 +03:00
Roman Vynar
828a5b72e3 Update changelog 2020-07-10 16:14:39 +03:00
Roman Vynar
2d3e770e6f Bump go version and dependencies 2020-07-10 15:43:01 +03:00
Roman Vynar
88964cb46e Use deffault logging level for PurgeOldTags() 2020-07-10 15:42:44 +03:00
Roman Vynar
c022edba0f Update README and screenshots. 2020-02-20 18:44:42 +02:00
Roman Vynar
f9899cb785 Minor amendments for the tag info page to account the cache type of sub-image. 2020-02-20 14:53:12 +02:00
Roman Vynar
e1cd96ef12 Update changelog 2020-02-19 12:42:22 +02:00
Roman Vynar
65c9978bd1 Compatibility fix with docker_auth v1.5.0 2020-02-19 12:36:55 +02:00
Roman Vynar
b6398fa33c Hide repositories with 0 tags count 2020-02-19 11:29:54 +02:00
Roman Vynar
67d82c7d59 Amend tag info page, change logging.
* Amend representation of the tag info page
* Change logging library, add "-log-level" argument and put most of the logging into DEBUG mode
2020-02-18 23:31:56 +02:00
Roman Vynar
dc7b2e42fc Update the link of Quiq website 2020-02-17 13:23:58 +02:00
Roman Vynar
41e74f70a2 Upgrade Go and dependencies, support custom TZ, fix initial ownership of sqlite db dir.
* Upgrade Go version to 1.13.7, alpine to 3.11 and other dependencies.
* You can define timezone when running container by adding `TZ` env var, e.g. "-e TZ=America/Los_Angeles"
  (thanks to @gminog).
* Fix initial ownership of /opt/data dir in Dockerfile.
2020-02-17 13:13:53 +02:00
Christoph Honal
905e760956 Support V2 Manifest Lists (#45)
* Support V2 Manifest Lists

* Fix breadcrumb navigation for digest tags

* Support BuildX cache images & display extended attributes of manifests
2020-02-17 12:11:37 +02:00
29 changed files with 1000 additions and 464 deletions

5
.gitignore vendored
View File

@@ -1,3 +1,4 @@
config-dev.yml
data/registry_events.db
vendor/
config-dev.yml
dev.Makefile
keep_tags.json

View File

@@ -1,5 +1,54 @@
## Changelog
### 0.9.6 (2023-03-30)
* Upgrade Go version to 1.20.2, alpine to 3.17 and other dependencies.
### 0.9.5 (2022-09-05)
* Upgrade Go version to 1.19.0, alpine to 3.16 and other dependencies.
* Add an option `anyone_can_view_events` to restrict access to the event log. Set it to `true` to make event log accessible to anyone (to restore the previous behaviour), otherwise the default `false` will hide it and only admins can view it (thanks to @ribbybibby).
* Add an option `purge_tags_keep_regexp` to preserve tags based on regexp (thanks to @dmaes).
* Add an option `purge_tags_keep_from_file` to preserve tags for repos listed in the file provided.
* When purging tags sort them by name reversibly when no date available, e.g. for OCI image format (thanks to @dmaes).
* Fix a bug when there was a bit more tags preserved than defined by `purge_tags_keep_count`.
Also see `config.yml` in this repo for the description of new options.
### 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.

View File

@@ -1,12 +1,9 @@
FROM golang:1.12.7-alpine3.9 as builder
ENV GOPATH /opt
ENV GO111MODULE on
FROM golang:1.20.2-alpine3.17 as builder
RUN apk update && \
apk add ca-certificates git bash gcc musl-dev
WORKDIR /opt/src/github.com/quiq/docker-registry-ui
WORKDIR /opt/src
ADD events events
ADD registry registry
ADD *.go go.mod go.sum ./
@@ -15,11 +12,12 @@ RUN go test -v ./registry && \
go build -o /opt/docker-registry-ui *.go
FROM alpine:3.9
FROM alpine:3.17
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

201
LICENSE Normal file
View File

@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,13 +0,0 @@
Copyright 2017-2018 Quiq Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

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

View File

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

84
config.go Normal file
View File

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

View File

@@ -32,13 +32,15 @@ event_database_location: data/registry_events.db
# 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
event_deletion_enabled: true
# 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.
# If all users can view the event log. If set to false, then only admins listed below.
anyone_can_view_events: true
# If all users can delete tags. If set to false, then only admins listed below.
anyone_can_delete: false
# Users allowed to delete tags.
# This should be sent via X-WEBAUTH-USER header from your proxy.
@@ -50,6 +52,16 @@ debug: true
# 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
# Keep tags matching regexp no matter how old, e.g. '^latest$'
# Empty string disables this feature.
purge_tags_keep_regexp: ''
# Keep tags listed in the file no matter how old.
# File format is JSON: {"repo1": ["tag1", "tag2"], "repoX": ["tagX"]}
# Empty string disables this feature.
purge_tags_keep_from_file: ''
# 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.

View File

@@ -8,8 +8,8 @@ import (
"os"
"strings"
"github.com/hhkbp2/go-logging"
"github.com/quiq/docker-registry-ui/registry"
"github.com/sirupsen/logrus"
// 🐒 patching of "database/sql".
_ "github.com/go-sql-driver/mysql"
@@ -37,7 +37,7 @@ type EventListener struct {
databaseLocation string
retention int
eventDeletion bool
logger logging.Logger
logger *logrus.Entry
}
type eventData struct {

63
go.mod
View File

@@ -1,32 +1,39 @@
module github.com/quiq/docker-registry-ui
require (
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a // indirect
github.com/CloudyKit/jet v2.1.2+incompatible
github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect
github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a // indirect
github.com/go-sql-driver/mysql v1.4.1
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e // indirect
github.com/hhkbp2/go-logging v0.0.0-20171106042747-377ba05d9897
github.com/hhkbp2/go-strftime v0.0.0-20150709091403-d82166ec6782 // indirect
github.com/hhkbp2/testify v0.0.0-20150512090439-112845ebc045 // indirect
github.com/kr/pretty v0.1.0 // indirect
github.com/labstack/echo v3.3.10+incompatible
github.com/labstack/gommon v0.2.8 // indirect
github.com/mattn/go-colorable v0.1.0 // indirect
github.com/mattn/go-isatty v0.0.4 // indirect
github.com/mattn/go-sqlite3 v1.10.0
github.com/moul/http2curl v1.0.0 // indirect
github.com/parnurzeal/gorequest v0.2.15
github.com/pkg/errors v0.0.0-20180311214515-816c9085562c // indirect
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967
github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945
github.com/stretchr/testify v1.3.0 // indirect
github.com/tidwall/gjson v1.1.3
github.com/tidwall/match v1.0.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 // indirect
google.golang.org/appengine v1.3.0 // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
gopkg.in/yaml.v2 v2.2.2
github.com/CloudyKit/jet v2.1.3-0.20180809161101-62edd43e4f88+incompatible
github.com/go-sql-driver/mysql v1.7.0
github.com/labstack/echo/v4 v4.10.2
github.com/mattn/go-sqlite3 v1.14.16
github.com/parnurzeal/gorequest v0.2.16
github.com/robfig/cron v1.2.0
github.com/sirupsen/logrus v1.9.0
github.com/smartystreets/goconvey v1.7.2
github.com/tidwall/gjson v1.14.4
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 // indirect
github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect
github.com/jtolds/gls v4.20.0+incompatible // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/smartystreets/assertions v1.2.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.6.0 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.7.0 // indirect
golang.org/x/time v0.3.0 // indirect
moul.io/http2curl v1.0.0 // indirect
)
go 1.19

140
go.sum
View File

@@ -1,81 +1,89 @@
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a h1:3SgJcK9l5uPdBC/X17wanyJAMxM33+4ZhEIV96MIH8U=
github.com/CloudyKit/fastprinter v0.0.0-20170127035650-74b38d55f37a/go.mod h1:EFZQ978U7x8IRnstaskI3IysnWY5Ao3QgZUKOXlsAdw=
github.com/CloudyKit/jet v2.1.2+incompatible h1:ybZoYzMBdoijK6I+Ke3vg9GZsmlKo/ZhKdNMWz0P26c=
github.com/CloudyKit/jet v2.1.2+incompatible/go.mod h1:HPYO+50pSWkPoj9Q/eq0aRGByCL6ScRlUmiEX5Zgm+w=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
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/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a h1:A4wNiqeKqU56ZhtnzJCTyPZ1+cyu8jKtIchQ3TtxHgw=
github.com/elazarl/goproxy v0.0.0-20181111060418-2ce16c963a8a/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
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-20220529153421-8ea89ba92021 h1:EbF0UihnxWRcIMOwoVtqnAylsqcjzqpSvMdjF2Ud4rA=
github.com/elazarl/goproxy v0.0.0-20220529153421-8ea89ba92021/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
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/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e h1:JKmoR8x90Iww1ks85zJ1lfDGgIiMDuIptTOhJq+zKyg=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hhkbp2/go-logging v0.0.0-20171106042747-377ba05d9897 h1:0vxLTAKJQ8n7revuQ11xssUZbuyGwMuDGMRdaxrviuM=
github.com/hhkbp2/go-logging v0.0.0-20171106042747-377ba05d9897/go.mod h1:zAp/KbVJna4DHUdeSPYGsRNn9c62x569NIr9ssBuZ/I=
github.com/hhkbp2/go-strftime v0.0.0-20150709091403-d82166ec6782 h1:Evl9i7wBY3bjJ3NqHs0ldhnKOdQL4Kaum9ve1JAmiCE=
github.com/hhkbp2/go-strftime v0.0.0-20150709091403-d82166ec6782/go.mod h1:x8/IOQ5qQ4DKfiTmD9wBhQ40edg5wh7gMRwdLg07mMw=
github.com/hhkbp2/testify v0.0.0-20150512090439-112845ebc045 h1:MmQwR3zANTXzs2yZexVBDY6qcH2vJXOl/2dZFkWVM7w=
github.com/hhkbp2/testify v0.0.0-20150512090439-112845ebc045/go.mod h1:8DUHF4igllRoOCbQKJsylsDqROcRtPTdb+SQUfjCYLo=
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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/labstack/echo v3.3.10+incompatible h1:pGRcYk231ExFAyoAjAfD85kQzRJCRI8bbnE7CX5OEgg=
github.com/labstack/echo v3.3.10+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mattn/go-colorable v0.1.0 h1:v2XXALHHh6zHfYTJ+cSkwtyffnaOyR1MXaA91mTrb8o=
github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-sqlite3 v1.10.0 h1:jbhqpg7tQe4SupckyijYiy0mJJ/pRyHvXf7JdWK860o=
github.com/mattn/go-sqlite3 v1.10.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/parnurzeal/gorequest v0.2.15 h1:oPjDCsF5IkD4gUk6vIgsxYNaSgvAnIh1EJeROn3HdJU=
github.com/parnurzeal/gorequest v0.2.15/go.mod h1:3Kh2QUMJoqw3icWAecsyzkpY7UzRfDhbRdTjtNwNiUE=
github.com/pkg/errors v0.0.0-20180311214515-816c9085562c h1:F5RoIh7F9wB47PvXvpP1+Ihq1TkyC8iRdvwfKkESEZQ=
github.com/pkg/errors v0.0.0-20180311214515-816c9085562c/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/labstack/echo/v4 v4.10.2 h1:n1jAhnq/elIFTHr1EYpiYtyKgx4RW9ccVgkqByZaN2M=
github.com/labstack/echo/v4 v4.10.2/go.mod h1:OEyqf2//K1DFdE57vw2DRgWY0M7s65IVQO2FzvI4J5k=
github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8=
github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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 v0.0.0-20180505203441-b41be1df6967 h1:x7xEyJDP7Hv3LVgvWhzioQqbC/KtuUhTigKlH/8ehhE=
github.com/robfig/cron v0.0.0-20180505203441-b41be1df6967/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945 h1:N8Bg45zpk/UcpNGnfJt2y/3lRWASHNTUET8owPYCgYI=
github.com/smartystreets/goconvey v0.0.0-20190710185942-9d28bd7c0945/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
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.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
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.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/tidwall/gjson v1.1.3 h1:u4mspaByxY+Qk4U1QYYVzGFI8qxN/3jtEV0ZDb2vRic=
github.com/tidwall/gjson v1.1.3/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA=
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/tidwall/gjson v1.14.4 h1:uo0p8EbA09J7RQaflQ1aBRffTR7xedD2bcIVSYxLnkM=
github.com/tidwall/gjson v1.14.4/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 v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M=
github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a h1:oWX7TPOiFAMXLq8o0ikBYfCJVlRHBcsciT5bXOrH628=
golang.org/x/crypto v0.6.0 h1:qfktjS5LUO+fFKeJXZ+ikTRijMmljikvG68fpMMruSc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a h1:1BGLXjeY4akVXGgbC9HugT3Jv3hCI0z56oJR5vAMgBU=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384 h1:TFlARGu6Czu1z7q93HTxcP1P+/ZFC/IKythI5RzrnRg=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
google.golang.org/appengine v1.3.0 h1:FBSsiFRMz3LBeXIomRnVzrQwSDj4ibvcRexLG0LZGQk=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=

253
main.go
View File

@@ -3,102 +3,45 @@ package main
import (
"flag"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"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/tidwall/gjson"
"gopkg.in/yaml.v2"
"github.com/sirupsen/logrus"
)
type configData struct {
ListenAddr string `yaml:"listen_addr"`
BasePath string `yaml:"base_path"`
RegistryURL string `yaml:"registry_url"`
VerifyTLS bool `yaml:"verify_tls"`
Username string `yaml:"registry_username"`
Password string `yaml:"registry_password"`
PasswordFile string `yaml:"registry_password_file"`
EventListenerToken string `yaml:"event_listener_token"`
EventRetentionDays int `yaml:"event_retention_days"`
EventDatabaseDriver string `yaml:"event_database_driver"`
EventDatabaseLocation string `yaml:"event_database_location"`
EventDeletionEnabled bool `yaml:"event_deletion_enabled"`
CacheRefreshInterval uint8 `yaml:"cache_refresh_interval"`
AnyoneCanDelete bool `yaml:"anyone_can_delete"`
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 {
View *jet.Set
}
type apiClient struct {
client *registry.Client
eventListener *events.EventListener
config configData
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()
// Read config file.
if _, err := os.Stat(configFile); os.IsNotExist(err) {
panic(err)
}
bytes, err := ioutil.ReadFile(configFile)
if err != nil {
panic(err)
}
if err := yaml.Unmarshal(bytes, &a.config); err != nil {
panic(err)
}
// Validate registry URL.
u, err := url.Parse(a.config.RegistryURL)
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]
// Setup logging
if loggingLevel != "info" {
if level, err := logrus.ParseLevel(loggingLevel); err == nil {
logrus.SetLevel(level)
}
}
// 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")
}
// Read config file
a.config = readConfig(configFile)
a.config.PurgeConfig.DryRun = purgeDryRun
// Init registry API client.
a.client = registry.NewClient(a.config.RegistryURL, a.config.VerifyTLS, a.config.Username, a.config.Password)
@@ -106,19 +49,21 @@ func main() {
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
}
purgeFunc := func() {
registry.PurgeOldTags(a.client, a.config.PurgeConfig)
}
// Execute CLI task and exit.
if purgeTags {
a.purgeOldTags(purgeDryRun)
purgeFunc()
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))
if err := c.AddFunc(a.config.PurgeTagsSchedule, purgeFunc); err != nil {
panic(fmt.Errorf("invalid schedule format: %s", a.config.PurgeTagsSchedule))
}
c.Start()
}
@@ -135,7 +80,8 @@ func main() {
// Template engine init.
e := echo.New()
e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath)
registryHost, _ := url.Parse(a.config.RegistryURL) // validated already in config.go
e.Renderer = setupRenderer(a.config.Debug, registryHost.Host, a.config.BasePath)
// Web routes.
e.File("/favicon.ico", "static/favicon.ico")
@@ -161,148 +107,3 @@ func main() {
e.Logger.Fatal(e.Start(a.config.ListenAddr))
}
func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace")
if namespace == "" {
namespace = "library"
}
repos, _ := a.client.Repositories(true)[namespace]
data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces())
data.Set("repos", repos)
data.Set("tagCounts", a.client.TagCounts())
return c.Render(http.StatusOK, "repositories.html", data)
}
func (a *apiClient) viewTags(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := a.client.Tags(repoPath)
deleteAllowed := a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER"))
data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tags", tags)
data.Set("deleteAllowed", deleteAllowed)
repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data)
}
func (a *apiClient) viewTagInfo(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
if infoV1 == "" || infoV2 == "" {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
var imageSize int64
if gjson.Get(infoV2, "layers").Exists() {
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
imageSize = imageSize + s.Int()
}
} else {
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
}
}
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)
}
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
data := jet.VarMap{}
data.Set("namespace", namespace)
data.Set("repo", repo)
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("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
return c.Render(http.StatusOK, "tag_info.html", data)
}
func (a *apiClient) deleteTag(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
if a.checkDeletePermission(c.Request().Header.Get("X-WEBAUTH-USER")) {
a.client.DeleteTag(repoPath, tag)
}
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.
func (a *apiClient) checkDeletePermission(user string) bool {
deleteAllowed := a.config.AnyoneCanDelete
if !deleteAllowed {
for _, u := range a.config.Admins {
if u == user {
deleteAllowed = true
break
}
}
}
return deleteAllowed
}
// viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error {
data := jet.VarMap{}
data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data)
}
// 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)
}

View File

@@ -10,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
@@ -22,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
@@ -44,7 +46,8 @@ func NewClient(url string, verifyTLS bool, username, password string) *Client {
repos: map[string][]string{},
tagCounts: map[string]int{},
}
resp, _, errs := c.request.Get(c.url+"/v2/").Set("User-Agent", "docker-registry-ui").End()
resp, _, errs := c.request.Get(c.url+"/v2/").
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
return nil
@@ -82,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 ""
@@ -99,55 +106,50 @@ 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) (string, gorequest.Response) {
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 "", 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 "", resp
}
// Ensure Docker-Content-Digest header is present as we use it in various places.
// The header is probably in AWS ECR case.
digest := resp.Header.Get("Docker-Content-Digest")
if digest == "" {
// Try to get digest from body instead, should be equal to what would be presented
// in Docker-Content-Digest
// 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)))
}
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 "", resp
}
return data, resp
}
@@ -177,10 +179,11 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
linkRegexp := regexp.MustCompile("^<(.*?)>;.*$")
scope := "registry:catalog:*"
uri := "/v2/_catalog"
c.repos = map[string][]string{}
tmp := map[string][]string{}
for {
data, resp := c.callRegistry(uri, scope, 2, false)
data, resp := c.callRegistry(uri, scope, "manifest.v2")
if data == "" {
c.repos = tmp
return c.repos
}
@@ -192,7 +195,7 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
namespace = f[0]
repo = f[1]
}
c.repos[namespace] = append(c.repos[namespace], repo)
tmp[namespace] = append(tmp[namespace], repo)
}
// pagination
@@ -206,13 +209,14 @@ func (c *Client) Repositories(useCache bool) map[string][]string {
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())
@@ -220,25 +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, resp := 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
}
@@ -250,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 {
@@ -261,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)
}
}
@@ -269,5 +294,31 @@ func (c *Client) CountTags(interval uint8) {
// DeleteTag delete image tag.
func (c *Client) DeleteTag(repo, tag string) {
scope := fmt.Sprintf("repository:%s:*", repo)
c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, 2, true)
// Get sha256 digest for tag.
_, resp := c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.list.v2")
if resp.Header.Get("Content-Type") != "application/vnd.docker.distribution.manifest.list.v2+json" {
_, resp = c.callRegistry(fmt.Sprintf("/v2/%s/manifests/%s", repo, tag), scope, "manifest.v2")
}
// Delete by manifest digest reference.
authHeader := ""
if c.authURL != "" {
authHeader = fmt.Sprintf("Bearer %s", c.getToken(scope))
}
uri := fmt.Sprintf("/v2/%s/manifests/%s", repo, resp.Header.Get("Docker-Content-Digest"))
resp, _, errs := c.request.Delete(c.url+uri).
Set("Authorization", authHeader).
Set("User-Agent", userAgent).End()
if len(errs) > 0 {
c.logger.Error(errs[0])
} else {
// Returns 202 on success.
if !strings.Contains(repo, "/") {
c.tagCounts["library/"+repo]--
} else {
c.tagCounts[repo]--
}
c.logger.Infof("DELETE %s (tag:%s) %s", uri, tag, resp.Status)
}
}

View File

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

View File

@@ -1,12 +1,32 @@
package registry
import (
"math"
"testing"
"time"
"github.com/smartystreets/goconvey/convey"
)
func TestKeepMinCount(t *testing.T) {
keepTags := []string{"1.8.15"}
purgeTags := []string{"1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5", "1.8.4", "1.8.3"}
purgeTagsKeepCount := 10
// Keep minimal count of tags no matter how old they are.
if len(keepTags) < purgeTagsKeepCount {
// Min of threshold-keep but not more than purge.
takeFromPurge := int(math.Min(float64(purgeTagsKeepCount-len(keepTags)), float64(len(purgeTags))))
keepTags = append(keepTags, purgeTags[:takeFromPurge]...)
purgeTags = purgeTags[takeFromPurge:]
}
convey.Convey("Test keep min count logic", t, func() {
convey.So(keepTags, convey.ShouldResemble, []string{"1.8.15", "1.8.14", "1.8.13", "1.8.12", "1.8.10", "1.8.9", "1.8.8", "1.8.7", "1.8.6", "1.8.5"})
convey.So(purgeTags, convey.ShouldResemble, []string{"1.8.4", "1.8.3"})
})
}
func TestSortedMapKeys(t *testing.T) {
a := map[string]string{
"foo": "bar",
@@ -14,14 +34,14 @@ func TestSortedMapKeys(t *testing.T) {
"zoo": "bar",
}
b := map[string]timeSlice{
"zoo": []tagData{tagData{name: "1", created: time.Now()}},
"abc": []tagData{tagData{name: "1", created: time.Now()}},
"foo": []tagData{tagData{name: "1", created: time.Now()}},
"zoo": []tagData{{name: "1", created: time.Now()}},
"abc": []tagData{{name: "1", created: time.Now()}},
"foo": []tagData{{name: "1", created: time.Now()}},
}
c := map[string][]string{
"zoo": []string{"1", "2"},
"foo": []string{"1", "2"},
"abc": []string{"1", "2"},
"zoo": {"1", "2"},
"foo": {"1", "2"},
"abc": {"1", "2"},
}
expect := []string{"abc", "foo", "zoo"}
convey.Convey("Sort map keys", t, func() {
@@ -37,8 +57,8 @@ func TestPrettySize(t *testing.T) {
123: "123 B",
23123: "23 KB",
23923: "23 KB",
723425120: "690 MB",
8534241213: "8 GB",
723425120: "689.9 MB",
8534241213: "7.95 GB",
}
for key, val := range input {
convey.So(PrettySize(key), convey.ShouldEqual, val)

View File

@@ -2,13 +2,24 @@ package registry
import (
"fmt"
"io/ioutil"
"math"
"os"
"regexp"
"sort"
"time"
"github.com/hhkbp2/go-logging"
"github.com/tidwall/gjson"
)
type PurgeTagsConfig struct {
DryRun bool
KeepDays int
KeepMinCount int
KeepTagRegexp string
KeepFromFile string
}
type tagData struct {
name string
created time.Time
@@ -25,6 +36,11 @@ func (p timeSlice) Len() int {
}
func (p timeSlice) Less(i, j int) bool {
// reverse sort tags on name if equal dates (OCI image case)
// see https://github.com/Quiq/docker-registry-ui/pull/62
if p[i].created.Equal(p[j].created) {
return p[i].name > p[j].name
}
return p[i].created.After(p[j].created)
}
@@ -33,13 +49,27 @@ func (p timeSlice) Swap(i, j int) {
}
// PurgeOldTags purge old tags.
func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTagsKeepCount int) {
func PurgeOldTags(client *Client, config *PurgeTagsConfig) {
logger := SetupLogging("registry.tasks.PurgeOldTags")
// Reduce client logging.
client.logger.SetLevel(logging.LevelError)
var keepTagsFromFile gjson.Result
if config.KeepFromFile != "" {
if _, err := os.Stat(config.KeepFromFile); os.IsNotExist(err) {
logger.Warnf("Cannot open %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
data, err := ioutil.ReadFile(config.KeepFromFile)
if err != nil {
logger.Warnf("Cannot read %s: %s", config.KeepFromFile, err)
logger.Error("Not purging anything!")
return
}
keepTagsFromFile = gjson.ParseBytes(data)
}
dryRunText := ""
if purgeDryRun {
if config.DryRun {
logger.Warn("Dry-run mode enabled.")
dryRunText = "skipped"
}
@@ -57,10 +87,10 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
}
tags := client.Tags(repo)
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
if len(tags) == 0 {
continue
}
logger.Infof("[%s] scanning %d tags...", repo, len(tags))
for _, tag := range tags {
_, infoV1, _ := client.TagInfo(repo, tag, true)
if infoV1 == "" {
@@ -74,23 +104,35 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
}
logger.Infof("Scanned %d repositories.", count)
logger.Info("Filtering out tags for purging...")
logger.Infof("Filtering out tags for purging: keep %d days, keep count %d", config.KeepDays, config.KeepMinCount)
if config.KeepTagRegexp != "" {
logger.Infof("Keeping tags matching regexp: %s", config.KeepTagRegexp)
}
if config.KeepFromFile != "" {
logger.Infof("Keeping tags for repos from the file: %+v", keepTagsFromFile)
}
purgeTags := map[string][]string{}
keepTags := map[string][]string{}
count = 0
for _, repo := range SortedMapKeys(repos) {
// Sort tags by "created" from newest to oldest.
sortedTags := make(timeSlice, 0, len(repos[repo]))
for _, d := range repos[repo] {
sortedTags = append(sortedTags, d)
}
sort.Sort(sortedTags)
repos[repo] = sortedTags
sort.Sort(repos[repo])
// Filter out tags by retention days.
// Prep the list of tags to preserve if defined in the file
tagsFromFile := []string{}
for _, i := range keepTagsFromFile.Get(repo).Array() {
tagsFromFile = append(tagsFromFile, i.String())
}
// Filter out tags
for _, tag := range repos[repo] {
delta := int(now.Sub(tag.created).Hours() / 24)
if delta > purgeTagsKeepDays {
daysOld := int(now.Sub(tag.created).Hours() / 24)
keepByRegexp := false
if config.KeepTagRegexp != "" {
keepByRegexp, _ = regexp.MatchString(config.KeepTagRegexp, tag.name)
}
if daysOld > config.KeepDays && !keepByRegexp && !ItemInSlice(tag.name, tagsFromFile) {
purgeTags[repo] = append(purgeTags[repo], tag.name)
} else {
keepTags[repo] = append(keepTags[repo], tag.name)
@@ -98,14 +140,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
}
// Keep minimal count of tags no matter how old they are.
if len(repos[repo])-len(purgeTags[repo]) < purgeTagsKeepCount {
if len(purgeTags[repo]) > purgeTagsKeepCount {
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:purgeTagsKeepCount]...)
purgeTags[repo] = purgeTags[repo][purgeTagsKeepCount:]
} else {
keepTags[repo] = append(keepTags[repo], purgeTags[repo]...)
delete(purgeTags, repo)
}
if len(keepTags[repo]) < config.KeepMinCount {
// At least "threshold"-"keep" but not more than available for "purge".
takeFromPurge := int(math.Min(float64(config.KeepMinCount-len(keepTags[repo])), float64(len(purgeTags[repo]))))
keepTags[repo] = append(keepTags[repo], purgeTags[repo][:takeFromPurge]...)
purgeTags[repo] = purgeTags[repo][takeFromPurge:]
}
count = count + len(purgeTags[repo])
@@ -120,8 +159,11 @@ func PurgeOldTags(client *Client, purgeDryRun bool, purgeTagsKeepDays, purgeTags
}
for _, repo := range SortedMapKeys(purgeTags) {
if len(purgeTags[repo]) == 0 {
continue
}
logger.Infof("[%s] Purging %d tags... %s", repo, len(purgeTags[repo]), dryRunText)
if purgeDryRun {
if config.DryRun {
continue
}
for _, tag := range purgeTags[repo] {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 281 KiB

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 KiB

After

Width:  |  Height:  |  Size: 174 KiB

BIN
screenshots/3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

BIN
screenshots/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

View File

@@ -7,7 +7,7 @@ import (
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
)
@@ -21,7 +21,7 @@ type Template struct {
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))
panic(fmt.Errorf("fatal error template file: %s", err))
}
vars, ok := data.(jet.VarMap)
if !ok {
@@ -29,7 +29,7 @@ func (r *Template) Render(w io.Writer, name string, data interface{}, c echo.Con
}
err = t.Execute(w, vars, nil)
if err != nil {
panic(fmt.Errorf("Error rendering template %s: %s", name, err))
panic(fmt.Errorf("error rendering template %s: %s", name, err))
}
return nil
}

View File

@@ -14,16 +14,18 @@
<div style="float: left">
<h2><a href="{{ basePath }}/" style="text-decoration: none">Docker Registry UI</a></h2>
</div>
{{if eventsAllowed}}
<div style="float: right">
<h4><a href="{{ basePath }}/events">Event Log</a></h4>
</div>
{{end}}
<div style="clear: both"></div>
{{yield body()}}
<div style="padding: 10px 0; margin-bottom: 20px">
<div style="text-align: center; color:darkgrey">
Docker Registry UI v{{version}} &copy; 2017-2018 <a href="https://goquiq.com">Quiq Inc.</a>
Docker Registry UI v{{version}} &copy; by <a href="https://quiq.com">Quiq Inc.</a>
</div>
</div>
</div>

View File

@@ -20,6 +20,7 @@
<li class="active">Event Log</li>
</ol>
{{if eventsAllowed}}
<table id="datatable" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
@@ -46,4 +47,9 @@
{{end}}
</tbody>
</table>
{{else}}
<div class="text-center">
<h4>User "{{user}}" is not permitted to view the Event Log.</h4>
</div>
{{end}}
{{end}}

View File

@@ -56,10 +56,12 @@
</thead>
<tbody>
{{range repo := repos}}
{{if !isset(tagCounts[namespace+"/"+repo]) || (isset(tagCounts[namespace+"/"+repo]) && tagCounts[namespace+"/"+repo] > 0)}}
<tr>
<td><a href="{{ basePath }}/{{ namespace }}/{{ repo|url }}">{{ repo }}</a></td>
<td>{{ tagCounts[namespace+"/"+repo] }}</td>
</tr>
{{end}}
{{end}}
</tbody>
</table>

View File

@@ -11,31 +11,72 @@
<li><a href="{{ basePath }}/{{ namespace }}/{{ repo }}">{{ repo|url_decode }}</a></li>
<li class="active">{{ tag }}</li>
</ol>
<h4>Image Details</h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Image Details</th>
<th colspan="2">Summary</th>
</tr>
</thead>
<tr>
<td width="20%">Image</td><td>{{ registryHost }}/{{ repoPath }}:{{ tag }}</td>
<td width="20%"><b>Image URL</b></td><td>{{ registryHost }}/{{ repoPath }}{{ isDigest ? "@" : ":" }}{{ tag }}</td>
</tr>
<tr>
<td>sha256</td><td>{{ sha256 }}</td>
<td><b>Digest</b></td><td>sha256:{{ sha256 }}</td>
</tr>
{{if created}}
<tr>
<td><b>Created On</b></td><td>{{ created|pretty_time }}</td>
</tr>
{{end}}
{{if not digestList}}
<tr>
<td><b>Image Size</b></td><td>{{ imageSize|pretty_size }}</td>
</tr>
<tr>
<td>Created On</td><td>{{ created|pretty_time }}</td>
<td><b>Layer Count</b></td><td>{{ layersCount }}</td>
</tr>
{{end}}
<tr>
<td>Image Size</td><td>{{ imageSize|pretty_size }}</td>
</tr>
<tr>
<td>Layer Count</td><td>{{ layersCount }}</td>
<td><b>Manifest Formats</b></td>
<td>{{if not isDigest}}Manifest v2 schema 1{{else}}<font color="#c2c2c2">Manifest v2 schema 1</font>{{end}} |
{{if not digestList && layersV2}}Manifest v2 schema 2{{else}}<font color="#c2c2c2">Manifest v2 schema 2</font>{{end}} |
{{if digestList}}Manifest List v2 schema 2{{else}}<font color="#c2c2c2">Manifest List v2 schema 2</font>{{end}}
</td>
</tr>
</table>
{{if layersV2}}
<h4>Manifest v2</h4>
{{if digestList}}
<h4>Sub-images <!-- Manifest List v2 schema 2: multi-arch or cache image --></h4>
{{range index, manifest := digestList}}
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
<th colspan="2">Manifest #{{ index+1 }}</th>
</tr>
</thead>
{{range key := manifest["ordered_keys"]}}
<tr>
<td width="20%">{{ key }}</td>
{{if key == "platform" || key == "annotations"}}
<td style="padding: 0">
<table class="table table-bordered" style="padding: 0; width: 100%; margin-bottom: 0; min-height: 37px">
<!-- Nested range does not work. Iterating via filter over the map. -->
{{ manifest[key]|parse_map|raw }}
</table>
</td>
{{else if key == "size"}}
<td>{{ manifest[key]|pretty_size }}</td>
{{else}}
<td>{{ manifest[key]|raw }}</td>
{{end}}
</tr>
{{end}}
</table>
{{end}}
{{else if layersV2}}
<h4>Blobs <!-- Manifest v2 schema 2--></h4>
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
<tr>
@@ -54,7 +95,8 @@
</table>
{{end}}
<h4>Manifest v1</h4>
{{if not isDigest}}
<h4>Image History <!-- Manifest v2 schema 1--></h4>
{{range index, layer := layersV1}}
<table class="table table-striped table-bordered">
<thead bgcolor="#ddd">
@@ -81,5 +123,6 @@
{{end}}
</table>
{{end}}
{{end}}
{{end}}

View File

@@ -59,6 +59,7 @@
</tbody>
</table>
{{if eventsAllowed}}
<h4>Latest events on this repo</h4>
<table id="datatable_log" class="table table-striped table-bordered">
<thead bgcolor="#ddd">
@@ -86,5 +87,6 @@
{{end}}
</tbody>
</table>
{{end}}
{{end}}

View File

@@ -1,3 +1,3 @@
package main
const version = "0.8.2"
const version = "0.9.6"

189
web.go Normal file
View File

@@ -0,0 +1,189 @@
package main
import (
"fmt"
"net/http"
"net/url"
"strings"
"github.com/CloudyKit/jet"
"github.com/labstack/echo/v4"
"github.com/quiq/docker-registry-ui/registry"
"github.com/tidwall/gjson"
)
const usernameHTTPHeader = "X-WEBAUTH-USER"
func (a *apiClient) setUserPermissions(c echo.Context) jet.VarMap {
user := c.Request().Header.Get(usernameHTTPHeader)
data := jet.VarMap{}
data.Set("user", user)
data.Set("eventsAllowed", a.config.AnyoneCanViewEvents || registry.ItemInSlice(user, a.config.Admins))
data.Set("deleteAllowed", a.config.AnyoneCanDelete || registry.ItemInSlice(user, a.config.Admins))
return data
}
func (a *apiClient) viewRepositories(c echo.Context) error {
namespace := c.Param("namespace")
if namespace == "" {
namespace = "library"
}
repos := a.client.Repositories(true)[namespace]
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("namespaces", a.client.Namespaces())
data.Set("repos", repos)
data.Set("tagCounts", a.client.TagCounts())
return c.Render(http.StatusOK, "repositories.html", data)
}
func (a *apiClient) viewTags(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
tags := a.client.Tags(repoPath)
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tags", tags)
repoPath, _ = url.PathUnescape(repoPath)
data.Set("events", a.eventListener.GetEvents(repoPath))
return c.Render(http.StatusOK, "tags.html", data)
}
func (a *apiClient) viewTagInfo(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
// Retrieve full image info from various versions of manifests
sha256, infoV1, infoV2 := a.client.TagInfo(repoPath, tag, false)
sha256list, manifests := a.client.ManifestList(repoPath, tag)
if (infoV1 == "" || infoV2 == "") && len(manifests) == 0 {
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
created := gjson.Get(gjson.Get(infoV1, "history.0.v1Compatibility").String(), "created").String()
isDigest := strings.HasPrefix(tag, "sha256:")
if len(manifests) > 0 {
sha256 = sha256list
}
// Gather layers v2
var layersV2 []map[string]gjson.Result
for _, s := range gjson.Get(infoV2, "layers").Array() {
layersV2 = append(layersV2, s.Map())
}
// Gather layers v1
var layersV1 []map[string]interface{}
for _, s := range gjson.Get(infoV1, "history.#.v1Compatibility").Array() {
m, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
// Sort key in the map to show the ordered on UI.
m["ordered_keys"] = registry.SortedMapKeys(m)
layersV1 = append(layersV1, m)
}
// Count image size
var imageSize int64
if gjson.Get(infoV2, "layers").Exists() {
for _, s := range gjson.Get(infoV2, "layers.#.size").Array() {
imageSize = imageSize + s.Int()
}
} else {
for _, s := range gjson.Get(infoV2, "history.#.v1Compatibility").Array() {
imageSize = imageSize + gjson.Get(s.String(), "Size").Int()
}
}
// Count layers
layersCount := len(layersV2)
if layersCount == 0 {
layersCount = len(gjson.Get(infoV1, "fsLayers").Array())
}
// Gather sub-image info of multi-arch or cache image
var digestList []map[string]interface{}
for _, s := range manifests {
r, _ := gjson.Parse(s.String()).Value().(map[string]interface{})
if s.Get("mediaType").String() == "application/vnd.docker.distribution.manifest.v2+json" {
// Sub-image of the specific arch.
_, dInfoV1, _ := a.client.TagInfo(repoPath, s.Get("digest").String(), true)
var dSize int64
for _, d := range gjson.Get(dInfoV1, "layers.#.size").Array() {
dSize = dSize + d.Int()
}
r["size"] = dSize
// Create link here because there is a bug with jet template when referencing a value by map key in the "if" condition under "range".
if r["mediaType"] == "application/vnd.docker.distribution.manifest.v2+json" {
r["digest"] = fmt.Sprintf(`<a href="%s/%s/%s/%s">%s</a>`, a.config.BasePath, namespace, repo, r["digest"], r["digest"])
}
} else {
// Sub-image of the cache type.
r["size"] = s.Get("size").Int()
}
r["ordered_keys"] = registry.SortedMapKeys(r)
digestList = append(digestList, r)
}
// Populate template vars
data := a.setUserPermissions(c)
data.Set("namespace", namespace)
data.Set("repo", repo)
data.Set("tag", tag)
data.Set("repoPath", repoPath)
data.Set("sha256", sha256)
data.Set("imageSize", imageSize)
data.Set("created", created)
data.Set("layersCount", layersCount)
data.Set("layersV2", layersV2)
data.Set("layersV1", layersV1)
data.Set("isDigest", isDigest)
data.Set("digestList", digestList)
return c.Render(http.StatusOK, "tag_info.html", data)
}
func (a *apiClient) deleteTag(c echo.Context) error {
namespace := c.Param("namespace")
repo := c.Param("repo")
tag := c.Param("tag")
repoPath := repo
if namespace != "library" {
repoPath = fmt.Sprintf("%s/%s", namespace, repo)
}
data := a.setUserPermissions(c)
if data["deleteAllowed"].Bool() {
a.client.DeleteTag(repoPath, tag)
}
return c.Redirect(http.StatusSeeOther, fmt.Sprintf("%s/%s/%s", a.config.BasePath, namespace, repo))
}
// viewLog view events from sqlite.
func (a *apiClient) viewLog(c echo.Context) error {
data := a.setUserPermissions(c)
data.Set("events", a.eventListener.GetEvents(""))
return c.Render(http.StatusOK, "event_log.html", data)
}
// receiveEvents receive events.
func (a *apiClient) receiveEvents(c echo.Context) error {
a.eventListener.ProcessEvents(c.Request())
return c.String(http.StatusOK, "OK")
}