mirror of
https://github.com/Quiq/docker-registry-ui.git
synced 2025-07-17 15:51:27 +00:00
306 lines
9.1 KiB
Go
306 lines
9.1 KiB
Go
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/quiq/docker-registry-ui/events"
|
|
"github.com/quiq/docker-registry-ui/registry"
|
|
"github.com/robfig/cron"
|
|
"github.com/tidwall/gjson"
|
|
"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"`
|
|
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
|
|
}
|
|
|
|
func main() {
|
|
var (
|
|
a apiClient
|
|
configFile string
|
|
purgeTags bool
|
|
purgeDryRun bool
|
|
)
|
|
flag.StringVar(&configFile, "config-file", "config.yml", "path to the config file")
|
|
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]
|
|
}
|
|
}
|
|
// 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)
|
|
if a.client == nil {
|
|
panic(fmt.Errorf("cannot initialize api client or unsupported auth method"))
|
|
}
|
|
|
|
// Execute CLI task and exit.
|
|
if purgeTags {
|
|
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)
|
|
|
|
// Template engine init.
|
|
e := echo.New()
|
|
e.Renderer = setupRenderer(a.config.Debug, u.Host, a.config.BasePath)
|
|
|
|
// Web routes.
|
|
e.File("/favicon.ico", "static/favicon.ico")
|
|
e.Static(a.config.BasePath+"/static", "static")
|
|
if a.config.BasePath != "" {
|
|
e.GET(a.config.BasePath, a.viewRepositories)
|
|
}
|
|
e.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(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.receiveEvents)
|
|
|
|
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)
|
|
}
|