Better support on same prefix at the same level in the KV
This commit is contained in:
parent
ec6e46e2cb
commit
5eda08e9b8
5 changed files with 191 additions and 153 deletions
6
Gopkg.lock
generated
6
Gopkg.lock
generated
|
@ -257,8 +257,8 @@
|
||||||
[[projects]]
|
[[projects]]
|
||||||
name = "github.com/containous/staert"
|
name = "github.com/containous/staert"
|
||||||
packages = ["."]
|
packages = ["."]
|
||||||
revision = "cc00c303ccbd2491ddc1dccc9eb7ccadd807557e"
|
revision = "66717a0e0ca950c4b6dc8c87b46da0b8495c6e41"
|
||||||
version = "v3.1.0"
|
version = "v3.1.1"
|
||||||
|
|
||||||
[[projects]]
|
[[projects]]
|
||||||
name = "github.com/containous/traefik-extra-service-fabric"
|
name = "github.com/containous/traefik-extra-service-fabric"
|
||||||
|
@ -1679,6 +1679,6 @@
|
||||||
[solve-meta]
|
[solve-meta]
|
||||||
analyzer-name = "dep"
|
analyzer-name = "dep"
|
||||||
analyzer-version = 1
|
analyzer-version = 1
|
||||||
inputs-digest = "ac06fad81167510635546d4e5500b938d61f2eb999bf04d5520d7967b9621f0d"
|
inputs-digest = "ad34e6336e6f19b82c52e991d22c5b43b9144ed7dc83d7b17197583ace43f346"
|
||||||
solver-name = "gps-cdcl"
|
solver-name = "gps-cdcl"
|
||||||
solver-version = 1
|
solver-version = 1
|
||||||
|
|
|
@ -62,7 +62,7 @@
|
||||||
|
|
||||||
[[constraint]]
|
[[constraint]]
|
||||||
name = "github.com/containous/staert"
|
name = "github.com/containous/staert"
|
||||||
version = "3.1.0"
|
version = "3.1.1"
|
||||||
|
|
||||||
[[constraint]]
|
[[constraint]]
|
||||||
name = "github.com/containous/traefik-extra-service-fabric"
|
name = "github.com/containous/traefik-extra-service-fabric"
|
||||||
|
|
45
vendor/github.com/containous/staert/kv.go
generated
vendored
45
vendor/github.com/containous/staert/kv.go
generated
vendored
|
@ -46,16 +46,16 @@ func (kv *KvSource) Parse(cmd *flaeg.Command) (*flaeg.Command, error) {
|
||||||
|
|
||||||
// LoadConfig loads data from the KV Store into the config structure (given by reference)
|
// LoadConfig loads data from the KV Store into the config structure (given by reference)
|
||||||
func (kv *KvSource) LoadConfig(config interface{}) error {
|
func (kv *KvSource) LoadConfig(config interface{}) error {
|
||||||
pairs := map[string][]byte{}
|
pairs, err := kv.ListValuedPairWithPrefix(kv.Prefix)
|
||||||
if err := kv.ListRecursive(kv.Prefix, pairs); err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// fmt.Printf("pairs : %#v\n", pairs)
|
|
||||||
mapStruct, err := generateMapstructure(convertPairs(pairs), kv.Prefix)
|
mapStruct, err := generateMapstructure(convertPairs(pairs), kv.Prefix)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// fmt.Printf("mapStruct : %#v\n", mapStruct)
|
|
||||||
configDecoder := &mapstructure.DecoderConfig{
|
configDecoder := &mapstructure.DecoderConfig{
|
||||||
Metadata: nil,
|
Metadata: nil,
|
||||||
Result: config,
|
Result: config,
|
||||||
|
@ -77,11 +77,11 @@ func generateMapstructure(pairs []*store.KVPair, prefix string) (map[string]inte
|
||||||
for _, p := range pairs {
|
for _, p := range pairs {
|
||||||
// Trim the prefix off our key first
|
// Trim the prefix off our key first
|
||||||
key := strings.TrimPrefix(strings.Trim(p.Key, "/"), strings.Trim(prefix, "/")+"/")
|
key := strings.TrimPrefix(strings.Trim(p.Key, "/"), strings.Trim(prefix, "/")+"/")
|
||||||
raw, err := processKV(key, p.Value, raw)
|
var err error
|
||||||
|
raw, err = processKV(key, p.Value, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return raw, err
|
return raw, err
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
|
@ -313,15 +313,23 @@ func collateKvRecursive(objValue reflect.Value, kv map[string]string, key string
|
||||||
func writeCompressedData(data []byte) (string, error) {
|
func writeCompressedData(data []byte) (string, error) {
|
||||||
var buffer bytes.Buffer
|
var buffer bytes.Buffer
|
||||||
gzipWriter := gzip.NewWriter(&buffer)
|
gzipWriter := gzip.NewWriter(&buffer)
|
||||||
|
|
||||||
_, err := gzipWriter.Write(data)
|
_, err := gzipWriter.Write(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
gzipWriter.Close()
|
|
||||||
|
err = gzipWriter.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
return buffer.String(), nil
|
return buffer.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRecursive lists all key value children under key
|
// ListRecursive lists all key value children under key
|
||||||
|
// Replaced by ListValuedPairWithPrefix
|
||||||
|
// Deprecated
|
||||||
func (kv *KvSource) ListRecursive(key string, pairs map[string][]byte) error {
|
func (kv *KvSource) ListRecursive(key string, pairs map[string][]byte) error {
|
||||||
pairsN1, err := kv.List(key, nil)
|
pairsN1, err := kv.List(key, nil)
|
||||||
if err == store.ErrKeyNotFound {
|
if err == store.ErrKeyNotFound {
|
||||||
|
@ -342,14 +350,37 @@ func (kv *KvSource) ListRecursive(key string, pairs map[string][]byte) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
for _, p := range pairsN1 {
|
for _, p := range pairsN1 {
|
||||||
|
if p.Key != key {
|
||||||
err := kv.ListRecursive(p.Key, pairs)
|
err := kv.ListRecursive(p.Key, pairs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListValuedPairWithPrefix lists all key value children under key
|
||||||
|
func (kv *KvSource) ListValuedPairWithPrefix(key string) (map[string][]byte, error) {
|
||||||
|
pairs := make(map[string][]byte)
|
||||||
|
|
||||||
|
pairsN1, err := kv.List(key, nil)
|
||||||
|
if err == store.ErrKeyNotFound {
|
||||||
|
return pairs, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return pairs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range pairsN1 {
|
||||||
|
if len(p.Value) > 0 {
|
||||||
|
pairs[p.Key] = p.Value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pairs, nil
|
||||||
|
}
|
||||||
|
|
||||||
func convertPairs(pairs map[string][]byte) []*store.KVPair {
|
func convertPairs(pairs map[string][]byte) []*store.KVPair {
|
||||||
slicePairs := make([]*store.KVPair, len(pairs))
|
slicePairs := make([]*store.KVPair, len(pairs))
|
||||||
i := 0
|
i := 0
|
||||||
|
|
164
vendor/github.com/containous/staert/staert.go
generated
vendored
164
vendor/github.com/containous/staert/staert.go
generated
vendored
|
@ -2,12 +2,8 @@ package staert
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
|
||||||
"github.com/containous/flaeg"
|
"github.com/containous/flaeg"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -24,10 +20,7 @@ type Staert struct {
|
||||||
|
|
||||||
// NewStaert creates and return a pointer on Staert. Need defaultConfig and defaultPointersConfig given by references
|
// NewStaert creates and return a pointer on Staert. Need defaultConfig and defaultPointersConfig given by references
|
||||||
func NewStaert(rootCommand *flaeg.Command) *Staert {
|
func NewStaert(rootCommand *flaeg.Command) *Staert {
|
||||||
s := Staert{
|
return &Staert{command: rootCommand}
|
||||||
command: rootCommand,
|
|
||||||
}
|
|
||||||
return &s
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSource adds new Source to Staert, give it by reference
|
// AddSource adds new Source to Staert, give it by reference
|
||||||
|
@ -35,40 +28,31 @@ func (s *Staert) AddSource(src Source) {
|
||||||
s.sources = append(s.sources, src)
|
s.sources = append(s.sources, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getConfig for a flaeg.Command run sources Parse func in the raw
|
|
||||||
func (s *Staert) parseConfigAllSources(cmd *flaeg.Command) error {
|
|
||||||
for _, src := range s.sources {
|
|
||||||
var err error
|
|
||||||
_, err = src.Parse(cmd)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadConfig check which command is called and parses config
|
// LoadConfig check which command is called and parses config
|
||||||
// It returns the the parsed config or an error if it fails
|
// It returns the the parsed config or an error if it fails
|
||||||
func (s *Staert) LoadConfig() (interface{}, error) {
|
func (s *Staert) LoadConfig() (interface{}, error) {
|
||||||
for _, src := range s.sources {
|
for _, src := range s.sources {
|
||||||
//Type assertion
|
// Type assertion
|
||||||
f, ok := src.(*flaeg.Flaeg)
|
if flg, ok := src.(*flaeg.Flaeg); ok {
|
||||||
if ok {
|
fCmd, err := flg.GetCommand()
|
||||||
if fCmd, err := f.GetCommand(); err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
} else if s.command != fCmd {
|
}
|
||||||
//IF fleag sub-command
|
|
||||||
|
// if fleag sub-command
|
||||||
|
if s.command != fCmd {
|
||||||
|
// if parseAllSources
|
||||||
if fCmd.Metadata["parseAllSources"] == "true" {
|
if fCmd.Metadata["parseAllSources"] == "true" {
|
||||||
//IF parseAllSources
|
|
||||||
fCmdConfigType := reflect.TypeOf(fCmd.Config)
|
fCmdConfigType := reflect.TypeOf(fCmd.Config)
|
||||||
sCmdConfigType := reflect.TypeOf(s.command.Config)
|
sCmdConfigType := reflect.TypeOf(s.command.Config)
|
||||||
if fCmdConfigType != sCmdConfigType {
|
if fCmdConfigType != sCmdConfigType {
|
||||||
return nil, fmt.Errorf("command %s : Config type doesn't match with root command config type. Expected %s got %s", fCmd.Name, sCmdConfigType.Name(), fCmdConfigType.Name())
|
return nil, fmt.Errorf("command %s : Config type doesn't match with root command config type. Expected %s got %s",
|
||||||
|
fCmd.Name, sCmdConfigType.Name(), fCmdConfigType.Name())
|
||||||
}
|
}
|
||||||
s.command = fCmd
|
s.command = fCmd
|
||||||
} else {
|
} else {
|
||||||
// ELSE (not parseAllSources)
|
// (not parseAllSources)
|
||||||
s.command, err = f.Parse(fCmd)
|
s.command, err = flg.Parse(fCmd)
|
||||||
return s.command.Config, err
|
return s.command.Config, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,117 +62,19 @@ func (s *Staert) LoadConfig() (interface{}, error) {
|
||||||
return s.command.Config, err
|
return s.command.Config, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseConfigAllSources getConfig for a flaeg.Command run sources Parse func in the raw
|
||||||
|
func (s *Staert) parseConfigAllSources(cmd *flaeg.Command) error {
|
||||||
|
for _, src := range s.sources {
|
||||||
|
_, err := src.Parse(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Run calls the Run func of the command
|
// Run calls the Run func of the command
|
||||||
// Warning, Run doesn't parse the config
|
// Warning, Run doesn't parse the config
|
||||||
func (s *Staert) Run() error {
|
func (s *Staert) Run() error {
|
||||||
return s.command.Run()
|
return s.command.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
//TomlSource impement Source
|
|
||||||
type TomlSource struct {
|
|
||||||
filename string
|
|
||||||
dirNfullpath []string
|
|
||||||
fullpath string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewTomlSource creates and return a pointer on TomlSource.
|
|
||||||
// Parameter filename is the file name (without extension type, ".toml" will be added)
|
|
||||||
// dirNfullpath may contain directories or fullpath to the file.
|
|
||||||
func NewTomlSource(filename string, dirNfullpath []string) *TomlSource {
|
|
||||||
return &TomlSource{filename, dirNfullpath, ""}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConfigFileUsed return config file used
|
|
||||||
func (ts *TomlSource) ConfigFileUsed() string {
|
|
||||||
return ts.fullpath
|
|
||||||
}
|
|
||||||
|
|
||||||
func preprocessDir(dirIn string) (string, error) {
|
|
||||||
dirOut := dirIn
|
|
||||||
expanded := os.ExpandEnv(dirIn)
|
|
||||||
dirOut, err := filepath.Abs(expanded)
|
|
||||||
return dirOut, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func findFile(filename string, dirNfile []string) string {
|
|
||||||
for _, df := range dirNfile {
|
|
||||||
if df != "" {
|
|
||||||
fullPath, _ := preprocessDir(df)
|
|
||||||
if fileInfo, err := os.Stat(fullPath); err == nil && !fileInfo.IsDir() {
|
|
||||||
return fullPath
|
|
||||||
}
|
|
||||||
|
|
||||||
fullPath = filepath.Join(fullPath, filename+".toml")
|
|
||||||
if fileInfo, err := os.Stat(fullPath); err == nil && !fileInfo.IsDir() {
|
|
||||||
return fullPath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse calls toml.DecodeFile() func
|
|
||||||
func (ts *TomlSource) Parse(cmd *flaeg.Command) (*flaeg.Command, error) {
|
|
||||||
ts.fullpath = findFile(ts.filename, ts.dirNfullpath)
|
|
||||||
if len(ts.fullpath) < 2 {
|
|
||||||
return cmd, nil
|
|
||||||
}
|
|
||||||
metadata, err := toml.DecodeFile(ts.fullpath, cmd.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
boolFlags, err := flaeg.GetBoolFlags(cmd.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
flaegArgs, hasUnderField, err := generateArgs(metadata, boolFlags)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// fmt.Println(flaegArgs)
|
|
||||||
err = flaeg.Load(cmd.Config, cmd.DefaultPointersConfig, flaegArgs)
|
|
||||||
//if err!= missing parser err
|
|
||||||
if err != nil && err != flaeg.ErrParserNotFound {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if hasUnderField {
|
|
||||||
_, err := toml.DecodeFile(ts.fullpath, cmd.Config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cmd, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func generateArgs(metadata toml.MetaData, flags []string) ([]string, bool, error) {
|
|
||||||
var flaegArgs []string
|
|
||||||
keys := metadata.Keys()
|
|
||||||
hasUnderField := false
|
|
||||||
for i, key := range keys {
|
|
||||||
// fmt.Println(key)
|
|
||||||
if metadata.Type(key.String()) == "Hash" {
|
|
||||||
// TOML hashes correspond to Go structs or maps.
|
|
||||||
// fmt.Printf("%s could be a ptr on a struct, or a map\n", key)
|
|
||||||
for j := i; j < len(keys); j++ {
|
|
||||||
// fmt.Printf("%s =? %s\n", keys[j].String(), "."+key.String())
|
|
||||||
if strings.Contains(keys[j].String(), key.String()+".") {
|
|
||||||
hasUnderField = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
match := false
|
|
||||||
for _, flag := range flags {
|
|
||||||
if flag == strings.ToLower(key.String()) {
|
|
||||||
match = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if match {
|
|
||||||
flaegArgs = append(flaegArgs, "--"+strings.ToLower(key.String()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return flaegArgs, hasUnderField, nil
|
|
||||||
}
|
|
||||||
|
|
121
vendor/github.com/containous/staert/toml.go
generated
vendored
Normal file
121
vendor/github.com/containous/staert/toml.go
generated
vendored
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
package staert
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
"github.com/containous/flaeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ Source = (*TomlSource)(nil)
|
||||||
|
|
||||||
|
// TomlSource implement staert.Source
|
||||||
|
type TomlSource struct {
|
||||||
|
filename string
|
||||||
|
dirNFullPath []string
|
||||||
|
fullPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTomlSource creates and return a pointer on Source.
|
||||||
|
// Parameter filename is the file name (without extension type, ".toml" will be added)
|
||||||
|
// dirNFullPath may contain directories or fullPath to the file.
|
||||||
|
func NewTomlSource(filename string, dirNFullPath []string) *TomlSource {
|
||||||
|
return &TomlSource{filename, dirNFullPath, ""}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFileUsed return config file used
|
||||||
|
func (ts *TomlSource) ConfigFileUsed() string {
|
||||||
|
return ts.fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse calls toml.DecodeFile() func
|
||||||
|
func (ts *TomlSource) Parse(cmd *flaeg.Command) (*flaeg.Command, error) {
|
||||||
|
ts.fullPath = findFile(ts.filename, ts.dirNFullPath)
|
||||||
|
if len(ts.fullPath) < 2 {
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := toml.DecodeFile(ts.fullPath, cmd.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
boolFlags, err := flaeg.GetBoolFlags(cmd.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
flgArgs, hasUnderField, err := generateArgs(metadata, boolFlags)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = flaeg.Load(cmd.Config, cmd.DefaultPointersConfig, flgArgs)
|
||||||
|
if err != nil && err != flaeg.ErrParserNotFound {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasUnderField {
|
||||||
|
_, err := toml.DecodeFile(ts.fullPath, cmd.Config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func preProcessDir(dirIn string) (string, error) {
|
||||||
|
expanded := os.ExpandEnv(dirIn)
|
||||||
|
return filepath.Abs(expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func findFile(filename string, dirNFile []string) string {
|
||||||
|
for _, df := range dirNFile {
|
||||||
|
if df != "" {
|
||||||
|
fullPath, _ := preProcessDir(df)
|
||||||
|
if fileInfo, err := os.Stat(fullPath); err == nil && !fileInfo.IsDir() {
|
||||||
|
return fullPath
|
||||||
|
}
|
||||||
|
|
||||||
|
fullPath = filepath.Join(fullPath, filename+".toml")
|
||||||
|
if fileInfo, err := os.Stat(fullPath); err == nil && !fileInfo.IsDir() {
|
||||||
|
return fullPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateArgs(metadata toml.MetaData, flags []string) ([]string, bool, error) {
|
||||||
|
var flgArgs []string
|
||||||
|
keys := metadata.Keys()
|
||||||
|
hasUnderField := false
|
||||||
|
|
||||||
|
for i, key := range keys {
|
||||||
|
if metadata.Type(key.String()) == "Hash" {
|
||||||
|
// TOML hashes correspond to Go structs or maps.
|
||||||
|
for j := i; j < len(keys); j++ {
|
||||||
|
if strings.Contains(keys[j].String(), key.String()+".") {
|
||||||
|
hasUnderField = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match := false
|
||||||
|
for _, flag := range flags {
|
||||||
|
if flag == strings.ToLower(key.String()) {
|
||||||
|
match = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
flgArgs = append(flgArgs, "--"+strings.ToLower(key.String()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return flgArgs, hasUnderField, nil
|
||||||
|
}
|
Loading…
Reference in a new issue