mirror of
https://github.com/project-zot/zot.git
synced 2024-12-16 21:56:37 -05:00
build: split functionality into separate binaries
zot: registry server zli: zot cli to interact with the zot registry zui: zot ui (proposed) zb: zot benchmark (proposed) Signed-off-by: Ramkumar Chinchani <rchincha@cisco.com>
This commit is contained in:
parent
c4d34b7269
commit
4896adad1b
12 changed files with 136 additions and 46 deletions
2
.github/workflows/ci-cd.yml
vendored
2
.github/workflows/ci-cd.yml
vendored
|
@ -60,7 +60,7 @@ jobs:
|
|||
uses: svenstaro/upload-release-action@v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: bin/zot*
|
||||
file: bin/z*
|
||||
tag: ${{ github.ref }}
|
||||
overwrite: true
|
||||
file_glob: true
|
||||
|
|
10
Makefile
10
Makefile
|
@ -14,7 +14,7 @@ OS ?= linux
|
|||
ARCH ?= amd64
|
||||
|
||||
.PHONY: all
|
||||
all: swagger binary binary-minimal binary-debug binary-arch binary-arch-minimal exporter-minimal verify-config test test-clean check
|
||||
all: swagger binary binary-minimal binary-debug binary-arch binary-arch-minimal cli cli-arch exporter-minimal verify-config test test-clean check
|
||||
|
||||
.PHONY: binary-minimal
|
||||
binary-minimal: swagger
|
||||
|
@ -36,6 +36,14 @@ binary-arch-minimal: swagger
|
|||
binary-arch: swagger
|
||||
env CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zot-$(ARCH) -tags extended,containers_image_openpgp -v -trimpath -ldflags "-X zotregistry.io/zot/pkg/api/config.Commit=${COMMIT} -X zotregistry.io/zot/pkg/api/config.BinaryType=extended -X zotregistry.io/zot/pkg/api/config.GoVersion=${GO_VERSION} -s -w" ./cmd/zot
|
||||
|
||||
.PHONY: cli
|
||||
cli:
|
||||
env CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zli -tags extended,containers_image_openpgp -v -trimpath -ldflags "-X zotregistry.io/zot/pkg/api/config.Commit=${COMMIT} -X zotregistry.io/zot/pkg/api/config.BinaryType=extended -X zotregistry.io/zot/pkg/api/config.GoVersion=${GO_VERSION} -s -w" ./cmd/zli
|
||||
|
||||
.PHONY: cli-arch
|
||||
cli-arch: swagger
|
||||
env CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zli-$(ARCH) -tags extended,containers_image_openpgp -v -trimpath -ldflags "-X zotregistry.io/zot/pkg/api/config.Commit=${COMMIT} -X zotregistry.io/zot/pkg/api/config.BinaryType=extended -X zotregistry.io/zot/pkg/api/config.GoVersion=${GO_VERSION} -s -w" ./cmd/zli
|
||||
|
||||
.PHONY: exporter-minimal
|
||||
exporter-minimal: swagger
|
||||
env CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -o bin/zot-exporter -tags minimal,containers_image_openpgp -v -trimpath ./cmd/exporter
|
||||
|
|
13
cmd/zli/main.go
Normal file
13
cmd/zli/main.go
Normal file
|
@ -0,0 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"zotregistry.io/zot/pkg/cli"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.NewCliRootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
23
cmd/zli/main_test.go
Normal file
23
cmd/zli/main_test.go
Normal file
|
@ -0,0 +1,23 @@
|
|||
package main_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
. "github.com/smartystreets/goconvey/convey"
|
||||
"zotregistry.io/zot/pkg/api"
|
||||
"zotregistry.io/zot/pkg/api/config"
|
||||
"zotregistry.io/zot/pkg/cli"
|
||||
)
|
||||
|
||||
func TestIntegration(t *testing.T) {
|
||||
Convey("Make a new controller", t, func() {
|
||||
conf := config.New()
|
||||
c := api.NewController(conf)
|
||||
So(c, ShouldNotBeNil)
|
||||
|
||||
cl := cli.NewCliRootCmd()
|
||||
So(cl, ShouldNotBeNil)
|
||||
|
||||
So(cl.Execute(), ShouldBeNil)
|
||||
})
|
||||
}
|
|
@ -7,7 +7,7 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
if err := cli.NewRootCmd().Execute(); err != nil {
|
||||
if err := cli.NewServerRootCmd().Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ func TestIntegration(t *testing.T) {
|
|||
c := api.NewController(conf)
|
||||
So(c, ShouldNotBeNil)
|
||||
|
||||
cl := cli.NewRootCmd()
|
||||
cl := cli.NewServerRootCmd()
|
||||
So(cl, ShouldNotBeNil)
|
||||
|
||||
So(cl.Execute(), ShouldBeNil)
|
||||
|
|
|
@ -34,7 +34,7 @@ var (
|
|||
ErrUnauthorizedAccess = errors.New("cli: unauthorized access. check credentials")
|
||||
ErrCannotResetConfigKey = errors.New("cli: cannot reset given config key")
|
||||
ErrConfigNotFound = errors.New("cli: config with the given name does not exist")
|
||||
ErrNoURLProvided = errors.New("cli: no URL provided in argument or via config. see 'zot config -h'")
|
||||
ErrNoURLProvided = errors.New("cli: no URL provided in argument or via config")
|
||||
ErrIllegalConfigKey = errors.New("cli: given config key is not allowed")
|
||||
ErrScanNotSupported = errors.New("search: scanning of image media type not supported")
|
||||
ErrCLITimeout = errors.New("cli: Query timed out while waiting for results")
|
||||
|
|
|
@ -31,8 +31,8 @@ func NewConfigCommand() *cobra.Command {
|
|||
configCmd := &cobra.Command{
|
||||
Use: "config <config-name> [variable] [value]",
|
||||
Example: examples,
|
||||
Short: "Configure zot CLI",
|
||||
Long: `Configure default parameters for CLI`,
|
||||
Short: "Configure zot registry parameters for CLI",
|
||||
Long: `Configure zot registry parameters for CLI`,
|
||||
Args: cobra.ArbitraryArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
|
@ -104,8 +104,8 @@ func NewConfigCommand() *cobra.Command {
|
|||
func NewConfigAddCommand() *cobra.Command {
|
||||
configAddCmd := &cobra.Command{
|
||||
Use: "add <config-name> <url>",
|
||||
Short: "Add configuration for a zot URL",
|
||||
Long: `Configure CLI for interaction with a zot server`,
|
||||
Short: "Add configuration for a zot registry",
|
||||
Long: "Add configuration for a zot registry",
|
||||
Args: cobra.ExactArgs(twoArgs),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
|
@ -405,16 +405,16 @@ func configNameExists(configs []interface{}, configName string) bool {
|
|||
}
|
||||
|
||||
const (
|
||||
examples = ` zot config add main https://zot-foo.com:8080
|
||||
zot config main url
|
||||
zot config main --list
|
||||
zot config --list`
|
||||
examples = ` zli config add main https://zot-foo.com:8080
|
||||
zli config main url
|
||||
zli config main --list
|
||||
zli config --list`
|
||||
|
||||
supportedOptions = `
|
||||
Useful variables:
|
||||
url zot server URL
|
||||
showspinner show spinner while loading data [true/false]
|
||||
verify-tls verify TLS Certificate verification of the server [default: true]`
|
||||
verify-tls enable TLS certificate verification of the server [default: true]`
|
||||
|
||||
nameKey = "_name"
|
||||
|
||||
|
|
|
@ -22,8 +22,8 @@ func NewCveCommand(searchService SearchService) *cobra.Command {
|
|||
|
||||
cveCmd := &cobra.Command{
|
||||
Use: "cve [config-name]",
|
||||
Short: "Lookup CVEs in images hosted on zot",
|
||||
Long: `List CVEs (Common Vulnerabilities and Exposures) of images hosted on a zot instance`,
|
||||
Short: "Lookup CVEs in images hosted on the zot registry",
|
||||
Long: `List CVEs (Common Vulnerabilities and Exposures) of images hosted on the zot registry`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
|
|
@ -23,8 +23,8 @@ func NewImageCommand(searchService SearchService) *cobra.Command {
|
|||
|
||||
imageCmd := &cobra.Command{
|
||||
Use: "images [config-name]",
|
||||
Short: "List hosted images",
|
||||
Long: `List images hosted on zot`,
|
||||
Short: "List images hosted on the zot registry",
|
||||
Long: `List images hosted on the zot registry`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
@ -145,6 +145,6 @@ func searchImage(searchConfig searchConfig) error {
|
|||
const (
|
||||
spinnerDuration = 150 * time.Millisecond
|
||||
usageFooter = `
|
||||
Run 'zot config -h' for details on [config-name] argument
|
||||
Run 'zli config -h' for details on [config-name] argument
|
||||
`
|
||||
)
|
||||
|
|
|
@ -157,7 +157,8 @@ func newVerifyCmd(conf *config.Config) *cobra.Command {
|
|||
return verifyCmd
|
||||
}
|
||||
|
||||
func NewRootCmd() *cobra.Command {
|
||||
// "zot" - registry server.
|
||||
func NewServerRootCmd() *cobra.Command {
|
||||
showVersion := false
|
||||
conf := config.New()
|
||||
|
||||
|
@ -175,12 +176,39 @@ func NewRootCmd() *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
// "serve"
|
||||
rootCmd.AddCommand(newServeCmd(conf))
|
||||
rootCmd.AddCommand(newScrubCmd(conf))
|
||||
// "verify"
|
||||
rootCmd.AddCommand(newVerifyCmd(conf))
|
||||
// "scrub"
|
||||
rootCmd.AddCommand(newScrubCmd(conf))
|
||||
// "version"
|
||||
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit")
|
||||
|
||||
return rootCmd
|
||||
}
|
||||
|
||||
// "zli" - client-side cli.
|
||||
func NewCliRootCmd() *cobra.Command {
|
||||
showVersion := false
|
||||
|
||||
rootCmd := &cobra.Command{
|
||||
Use: "zli",
|
||||
Short: "`zli`",
|
||||
Long: "`zli`",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if showVersion {
|
||||
log.Info().Str("distribution-spec", distspec.Version).Str("commit", config.Commit).
|
||||
Str("binary-type", config.BinaryType).Str("go version", config.GoVersion).Msg("version")
|
||||
}
|
||||
_ = cmd.Usage()
|
||||
cmd.SilenceErrors = false
|
||||
},
|
||||
}
|
||||
|
||||
// additional cmds
|
||||
enableCli(rootCmd)
|
||||
|
||||
// "version"
|
||||
rootCmd.Flags().BoolVarP(&showVersion, "version", "v", false, "show the version and exit")
|
||||
|
||||
return rootCmd
|
||||
|
|
|
@ -17,20 +17,38 @@ import (
|
|||
. "zotregistry.io/zot/test"
|
||||
)
|
||||
|
||||
func TestUsage(t *testing.T) {
|
||||
func TestServerUsage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
Convey("Test usage", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "help"}
|
||||
err := cli.NewRootCmd().Execute()
|
||||
err := cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Test version", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "--version"}
|
||||
err := cli.NewRootCmd().Execute()
|
||||
err := cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCliUsage(t *testing.T) {
|
||||
oldArgs := os.Args
|
||||
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
Convey("Test usage", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "help"}
|
||||
err := cli.NewCliRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Test version", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "--version"}
|
||||
err := cli.NewCliRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
}
|
||||
|
@ -42,19 +60,19 @@ func TestServe(t *testing.T) {
|
|||
|
||||
Convey("Test serve help", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "serve", "-h"}
|
||||
err := cli.NewRootCmd().Execute()
|
||||
err := cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Test serve config", t, func(c C) {
|
||||
Convey("unknown config", func(c C) {
|
||||
os.Args = []string{"cli_test", "serve", path.Join(os.TempDir(), "/x")}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("non-existent config", func(c C) {
|
||||
os.Args = []string{"cli_test", "serve", path.Join(os.TempDir(), "/x.yaml")}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("bad config", func(c C) {
|
||||
|
@ -67,7 +85,7 @@ func TestServe(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "serve", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
@ -87,7 +105,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify storage driver different than s3", t, func(c C) {
|
||||
|
@ -102,7 +120,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify subpath storage driver different than s3", t, func(c C) {
|
||||
|
@ -118,7 +136,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify w/ authorization and w/o authentication", t, func(c C) {
|
||||
|
@ -134,7 +152,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify w/ authorization and w/ authentication", t, func(c C) {
|
||||
|
@ -151,7 +169,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldNotPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldNotPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify w/ sync and w/o filesystem storage", t, func(c C) {
|
||||
|
@ -167,7 +185,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify with bad sync prefixes", t, func(c C) {
|
||||
|
@ -184,7 +202,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify with bad authorization repo patterns", t, func(c C) {
|
||||
|
@ -200,7 +218,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("Test verify sync config default tls value", t, func(c C) {
|
||||
|
@ -217,7 +235,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
err = cli.NewRootCmd().Execute()
|
||||
err = cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
|
@ -233,7 +251,7 @@ func TestVerify(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "verify", tmpfile.Name()}
|
||||
err = cli.NewRootCmd().Execute()
|
||||
err = cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
}
|
||||
|
@ -252,19 +270,19 @@ func TestScrub(t *testing.T) {
|
|||
|
||||
Convey("Test scrub help", t, func(c C) {
|
||||
os.Args = []string{"cli_test", "scrub", "-h"}
|
||||
err := cli.NewRootCmd().Execute()
|
||||
err := cli.NewServerRootCmd().Execute()
|
||||
So(err, ShouldBeNil)
|
||||
})
|
||||
|
||||
Convey("Test scrub config", t, func(c C) {
|
||||
Convey("non-existent config", func(c C) {
|
||||
os.Args = []string{"cli_test", "scrub", path.Join(os.TempDir(), "/x.yaml")}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("unknown config", func(c C) {
|
||||
os.Args = []string{"cli_test", "scrub", path.Join(os.TempDir(), "/x")}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("bad config", func(c C) {
|
||||
|
@ -277,7 +295,7 @@ func TestScrub(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "scrub", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("server is running", func(c C) {
|
||||
|
@ -331,7 +349,7 @@ func TestScrub(t *testing.T) {
|
|||
So(err, ShouldBeNil)
|
||||
|
||||
os.Args = []string{"cli_test", "scrub", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
|
||||
defer func(controller *api.Controller) {
|
||||
ctx := context.Background()
|
||||
|
@ -362,7 +380,7 @@ func TestScrub(t *testing.T) {
|
|||
err = tmpfile.Close()
|
||||
So(err, ShouldBeNil)
|
||||
os.Args = []string{"cli_test", "scrub", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
|
||||
Convey("bad index.json", func(c C) {
|
||||
|
@ -420,7 +438,7 @@ func TestScrub(t *testing.T) {
|
|||
So(err, ShouldBeNil)
|
||||
|
||||
os.Args = []string{"cli_test", "scrub", tmpfile.Name()}
|
||||
So(func() { _ = cli.NewRootCmd().Execute() }, ShouldPanic)
|
||||
So(func() { _ = cli.NewServerRootCmd().Execute() }, ShouldPanic)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue