Consolidate command configuration and setup (#221)
This commit is contained in:
parent
5d20cf8829
commit
ab029301bd
@ -1,82 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zeebo/errs"
|
||||
"golang.org/x/net/context"
|
||||
|
||||
"storj.io/storj/pkg/kademlia"
|
||||
proto "storj.io/storj/protos/overlay"
|
||||
)
|
||||
|
||||
// Config stores values from a farmer node config file
|
||||
type Config struct {
|
||||
NodeID string
|
||||
PsHost string
|
||||
PsPort string
|
||||
KadListenPort string
|
||||
KadPort string
|
||||
KadHost string
|
||||
PieceStoreDir string
|
||||
}
|
||||
|
||||
// SetConfigPath sets and returns viper config directory and filepath
|
||||
func SetConfigPath(fileName string) (configDir, configFile string, err error) {
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
configDir = filepath.Join(home, ".storj")
|
||||
configFile = filepath.Join(configDir, fileName+".yaml")
|
||||
|
||||
viper.SetConfigFile(configFile)
|
||||
|
||||
return configDir, configFile, nil
|
||||
}
|
||||
|
||||
// GetConfigValues returns a struct with config file values
|
||||
func GetConfigValues() Config {
|
||||
config := Config{
|
||||
NodeID: viper.GetString("piecestore.id"),
|
||||
PsHost: viper.GetString("piecestore.host"),
|
||||
PsPort: viper.GetString("piecestore.port"),
|
||||
KadListenPort: viper.GetString("kademlia.listen.port"),
|
||||
KadPort: viper.GetString("kademlia.port"),
|
||||
KadHost: viper.GetString("kademlia.host"),
|
||||
PieceStoreDir: viper.GetString("piecestore.dir"),
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
// ConnectToKad joins the Kademlia network
|
||||
func ConnectToKad(ctx context.Context, id, ip, kadListenPort, kadAddress string) (*kademlia.Kademlia, error) {
|
||||
node := proto.Node{
|
||||
Id: id,
|
||||
Address: &proto.NodeAddress{
|
||||
Transport: proto.NodeTransport_TCP,
|
||||
Address: kadAddress,
|
||||
},
|
||||
}
|
||||
|
||||
kad, err := kademlia.NewKademlia(kademlia.StringToNodeID(id), []proto.Node{node}, ip, kadListenPort)
|
||||
if err != nil {
|
||||
return nil, errs.New("Failed to instantiate new Kademlia: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := kad.ListenAndServe(); err != nil {
|
||||
return nil, errs.New("Failed to ListenAndServe on new Kademlia: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := kad.Bootstrap(ctx); err != nil {
|
||||
return nil, errs.New("Failed to Bootstrap on new Kademlia: %s", err.Error())
|
||||
}
|
||||
|
||||
return kad, nil
|
||||
}
|
@ -1,108 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
"storj.io/storj/pkg/kademlia"
|
||||
)
|
||||
|
||||
// createCmd represents the create command
|
||||
var createCmd = &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "Create a new farmer node",
|
||||
Long: "Create a config file and set values for a new farmer node",
|
||||
RunE: createNode,
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(createCmd)
|
||||
|
||||
// TODO@ASK: this does not create an identity
|
||||
nodeID, err := kademlia.NewID()
|
||||
if err != nil {
|
||||
zap.S().Fatal(err)
|
||||
}
|
||||
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
zap.S().Fatal(err)
|
||||
}
|
||||
|
||||
createCmd.Flags().String("kademliaHost", "bootstrap.storj.io", "Kademlia server `host`")
|
||||
createCmd.Flags().String("kademliaPort", "8080", "Kademlia server `port`")
|
||||
createCmd.Flags().String("kademliaListenPort", "7776", "Kademlia server `listen port`")
|
||||
createCmd.Flags().String("pieceStoreHost", "127.0.0.1", "Farmer's public ip/host")
|
||||
createCmd.Flags().String("pieceStorePort", "7777", "`port` where piece store data is accessed")
|
||||
createCmd.Flags().String("dir", home, "`dir` of drive being shared")
|
||||
|
||||
if err := viper.BindPFlag("kademlia.host", createCmd.Flags().Lookup("kademliaHost")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "kademlia.host")
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("kademlia.port", createCmd.Flags().Lookup("kademliaPort")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "kademlia.port")
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("kademlia.listen.port", createCmd.Flags().Lookup("kademliaListenPort")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "kademlia.listen.port")
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("piecestore.host", createCmd.Flags().Lookup("pieceStoreHost")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "piecestore.host")
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("piecestore.port", createCmd.Flags().Lookup("pieceStorePort")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "piecestore.port")
|
||||
}
|
||||
|
||||
if err := viper.BindPFlag("piecestore.dir", createCmd.Flags().Lookup("dir")); err != nil {
|
||||
zap.S().Fatalf("Failed to bind flag: %s", "piecestore.dir")
|
||||
}
|
||||
|
||||
viper.SetDefault("piecestore.id", nodeID.String())
|
||||
}
|
||||
|
||||
// createNode creates a config file for a new farmer node
|
||||
func createNode(cmd *cobra.Command, args []string) error {
|
||||
configDir, configFile, err := SetConfigPath(viper.GetString("piecestore.id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pieceStoreDir := viper.GetString("piecestore.dir")
|
||||
|
||||
err = os.MkdirAll(pieceStoreDir, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.MkdirAll(configDir, 0700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configFile); err == nil {
|
||||
return errs.New("Config already exists")
|
||||
}
|
||||
|
||||
err = viper.WriteConfigAs(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := viper.ConfigFileUsed()
|
||||
|
||||
fmt.Printf("Node %s created\n", viper.GetString("piecestore.id"))
|
||||
fmt.Println("Config: ", path)
|
||||
|
||||
return nil
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // sqlite driver
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zeebo/errs"
|
||||
)
|
||||
|
||||
// deleteCmd represents the delete command
|
||||
var deleteCmd = &cobra.Command{
|
||||
Use: "delete",
|
||||
Short: "Delete a farmer node by ID",
|
||||
Long: "Delete config and all data stored on node by node ID",
|
||||
RunE: deleteNode,
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(deleteCmd)
|
||||
}
|
||||
|
||||
// deleteNode deletes a farmer node by ID
|
||||
func deleteNode(cmd *cobra.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errs.New("No ID specified")
|
||||
}
|
||||
|
||||
nodeID := args[0]
|
||||
|
||||
_, configFile, err := SetConfigPath(nodeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := os.Stat(configFile); os.IsNotExist(err) {
|
||||
return errs.New("Invalid node ID. Config file does not exist")
|
||||
}
|
||||
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get folder for stored data
|
||||
piecestoreDir := viper.GetString("piecestore.dir")
|
||||
piecestoreDir = filepath.Join(piecestoreDir, fmt.Sprintf("store-%s", nodeID))
|
||||
|
||||
// remove all folders and files stored on node
|
||||
if err := os.RemoveAll(piecestoreDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// delete node config
|
||||
err = os.Remove(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Printf("Node %s deleted\n", nodeID)
|
||||
|
||||
return nil
|
||||
}
|
@ -1,383 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestCreate tests the farmer CLI create command
|
||||
func TestCreate(t *testing.T) {
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
tempFile, err := ioutil.TempFile(os.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
defer os.Remove(tempFile.Name())
|
||||
|
||||
tests := []struct {
|
||||
it string
|
||||
expectedConfig Config
|
||||
args []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
it: "should successfully write config with default values",
|
||||
expectedConfig: Config{
|
||||
NodeID: "",
|
||||
PsHost: "127.0.0.1",
|
||||
PsPort: "7777",
|
||||
KadListenPort: "7776",
|
||||
KadPort: "8080",
|
||||
KadHost: "bootstrap.storj.io",
|
||||
PieceStoreDir: home,
|
||||
},
|
||||
args: []string{
|
||||
"create",
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
it: "should successfully write config with flag values",
|
||||
expectedConfig: Config{
|
||||
NodeID: "",
|
||||
PsHost: "123.4.5.6",
|
||||
PsPort: "1234",
|
||||
KadListenPort: "4321",
|
||||
KadPort: "4444",
|
||||
KadHost: "hack@theplanet.com",
|
||||
PieceStoreDir: os.TempDir(),
|
||||
},
|
||||
args: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "123.4.5.6"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "1234"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "4321"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "4444"),
|
||||
fmt.Sprintf("--kademliaHost=%s", "hack@theplanet.com"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
it: "should err when a config with identical ID already exists",
|
||||
expectedConfig: Config{
|
||||
NodeID: "",
|
||||
PsHost: "123.4.5.6",
|
||||
PsPort: "1234",
|
||||
KadListenPort: "4321",
|
||||
KadPort: "4444",
|
||||
KadHost: "hack@theplanet.com",
|
||||
PieceStoreDir: home,
|
||||
},
|
||||
args: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--dir=%s", home),
|
||||
},
|
||||
err: "Config already exists",
|
||||
},
|
||||
{
|
||||
it: "should err when pieceStoreDir is not a directory",
|
||||
expectedConfig: Config{
|
||||
NodeID: "",
|
||||
PsHost: "123.4.5.6",
|
||||
PsPort: "1234",
|
||||
KadListenPort: "4321",
|
||||
KadPort: "4444",
|
||||
KadHost: "hack@theplanet.com",
|
||||
PieceStoreDir: tempFile.Name(),
|
||||
},
|
||||
args: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--dir=%s", tempFile.Name()),
|
||||
},
|
||||
err: fmt.Sprintf("mkdir %s: not a directory", tempFile.Name()),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.it, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
RootCmd.SetArgs(tt.args)
|
||||
|
||||
path := filepath.Join(home, ".storj", viper.GetString("piecestore.id")+".yaml")
|
||||
|
||||
if tt.err == "Config already exists" {
|
||||
_, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
defer os.Remove(path)
|
||||
|
||||
err := RootCmd.Execute()
|
||||
if tt.err != "" {
|
||||
if err != nil {
|
||||
assert.Equal(tt.err, err.Error())
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
viper.SetConfigFile(path)
|
||||
|
||||
err = viper.ReadInConfig()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
tt.expectedConfig.NodeID = viper.GetString("piecestore.id")
|
||||
|
||||
assert.Equal(tt.expectedConfig, GetConfigValues())
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestStart tests the farmer CLI start command
|
||||
func TestStart(t *testing.T) {
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
it string
|
||||
createArgs []string
|
||||
startArgs []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
it: "should err with no ID specified",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "127.0.0.1"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
startArgs: []string{
|
||||
"start",
|
||||
},
|
||||
err: "No ID specified",
|
||||
},
|
||||
{
|
||||
it: "should err with invalid Farmer IP",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "123"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
startArgs: []string{
|
||||
"start",
|
||||
viper.GetString("piecestore.id"),
|
||||
},
|
||||
err: "Failed to instantiate new Kademlia: lookup 123: no such host",
|
||||
},
|
||||
{
|
||||
it: "should err with missing Kademlia Listen Port",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", ""),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "127.0.0.1"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
startArgs: []string{
|
||||
"start",
|
||||
viper.GetString("piecestore.id"),
|
||||
},
|
||||
err: "Failed to instantiate new Kademlia: node error: must specify port in request to NewKademlia",
|
||||
},
|
||||
{
|
||||
it: "should err with missing IP",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", ""),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
startArgs: []string{
|
||||
"start",
|
||||
viper.GetString("piecestore.id"),
|
||||
},
|
||||
err: "Failed to instantiate new Kademlia: lookup : no such host",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.it, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
RootCmd.SetArgs(tt.createArgs)
|
||||
|
||||
err = RootCmd.Execute()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
path := filepath.Join(home, ".storj", viper.GetString("piecestore.id")+".yaml")
|
||||
defer os.Remove(path)
|
||||
|
||||
RootCmd.SetArgs(tt.startArgs)
|
||||
|
||||
err := RootCmd.Execute()
|
||||
if tt.err != "" {
|
||||
if err != nil {
|
||||
assert.Equal(tt.err, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDelete tests the farmer CLI delete command
|
||||
func TestDelete(t *testing.T) {
|
||||
home, err := homedir.Dir()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
it string
|
||||
createArgs []string
|
||||
deleteArgs []string
|
||||
err string
|
||||
}{
|
||||
{
|
||||
it: "should successfully delete node config",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "127.0.0.1"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
deleteArgs: []string{
|
||||
"delete",
|
||||
viper.GetString("piecestore.id"),
|
||||
},
|
||||
err: "",
|
||||
},
|
||||
{
|
||||
it: "should err with no ID specified",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "127.0.0.1"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
deleteArgs: []string{
|
||||
"delete",
|
||||
},
|
||||
err: "No ID specified",
|
||||
},
|
||||
{
|
||||
it: "should err with no ID specified",
|
||||
createArgs: []string{
|
||||
"create",
|
||||
fmt.Sprintf("--kademliaHost=%s", "bootstrap.storj.io"),
|
||||
fmt.Sprintf("--kademliaPort=%s", "8080"),
|
||||
fmt.Sprintf("--kademliaListenPort=%s", "7776"),
|
||||
fmt.Sprintf("--pieceStoreHost=%s", "127.0.0.1"),
|
||||
fmt.Sprintf("--pieceStorePort=%s", "7777"),
|
||||
fmt.Sprintf("--dir=%s", os.TempDir()),
|
||||
},
|
||||
deleteArgs: []string{
|
||||
"delete",
|
||||
"123",
|
||||
},
|
||||
err: "Invalid node ID. Config file does not exist",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.it, func(t *testing.T) {
|
||||
assert := assert.New(t)
|
||||
|
||||
RootCmd.SetArgs(tt.createArgs)
|
||||
|
||||
err := RootCmd.Execute()
|
||||
if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".storj", viper.GetString("piecestore.id")+".yaml")
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if tt.err != "" {
|
||||
defer os.Remove(configPath)
|
||||
}
|
||||
|
||||
RootCmd.SetArgs(tt.deleteArgs)
|
||||
|
||||
err = RootCmd.Execute()
|
||||
if tt.err != "" {
|
||||
if err != nil {
|
||||
assert.Equal(tt.err, err.Error())
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// if err is nil, file was not deleted
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
t.Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
m.Run()
|
||||
}
|
@ -1,15 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// RootCmd represents the base command when called without any subcommands
|
||||
var RootCmd = &cobra.Command{
|
||||
Use: "piecestore-farmer",
|
||||
Short: "Piecestore-Farmer CLI",
|
||||
Long: "Piecestore-Farmer command line utility for creating, starting, and deleting farmer nodes",
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3" // sqlite driver
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/net/context"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"storj.io/storj/pkg/piecestore/rpc/server"
|
||||
"storj.io/storj/pkg/piecestore/rpc/server/ttl"
|
||||
pb "storj.io/storj/protos/piecestore"
|
||||
)
|
||||
|
||||
// startCmd represents the start command
|
||||
var startCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start a farmer node by ID",
|
||||
Long: "Start farmer node by ID using farmer node config values",
|
||||
RunE: startNode,
|
||||
}
|
||||
|
||||
func init() {
|
||||
RootCmd.AddCommand(startCmd)
|
||||
}
|
||||
|
||||
// startNode starts a farmer node by ID
|
||||
func startNode(cmd *cobra.Command, args []string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
if len(args) == 0 {
|
||||
return errs.New("No ID specified")
|
||||
}
|
||||
|
||||
_, _, err := SetConfigPath(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = viper.ReadInConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := GetConfigValues()
|
||||
|
||||
dbPath := filepath.Join(config.PieceStoreDir, fmt.Sprintf("store-%s", config.NodeID), "ttl-data.db")
|
||||
dataDir := filepath.Join(config.PieceStoreDir, fmt.Sprintf("store-%s", config.NodeID), "piece-store-data")
|
||||
|
||||
_, err = ConnectToKad(ctx, config.NodeID, config.PsHost, config.KadListenPort, fmt.Sprintf("%s:%s", config.KadHost, config.KadPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ttlDB, err := ttl.NewTTL(dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// create a listener on TCP port
|
||||
lis, err := net.Listen("tcp", fmt.Sprintf(":%s", config.PsPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := lis.Close(); err != nil {
|
||||
zap.S().Fatalf("Error in listening: %v\n", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// create a server instance
|
||||
s := server.Server{PieceStoreDir: dataDir, DB: ttlDB}
|
||||
|
||||
// create a gRPC server object
|
||||
grpcServer := grpc.NewServer()
|
||||
|
||||
// attach the api service to the server
|
||||
pb.RegisterPieceStoreRoutesServer(grpcServer, &s)
|
||||
|
||||
// routinely check DB and delete expired entries
|
||||
go func() {
|
||||
err := s.DB.DBCleanup(dataDir)
|
||||
zap.S().Fatalf("Error in DBCleanup: %v\n", err)
|
||||
}()
|
||||
|
||||
fmt.Printf("Node %s started\n", config.NodeID)
|
||||
|
||||
// start the server
|
||||
if err := grpcServer.Serve(lis); err != nil {
|
||||
zap.S().Fatalf("failed to serve: %s\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@ -1,13 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"storj.io/storj/cmd/piecestore-farmer/cmd"
|
||||
"storj.io/storj/pkg/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
process.Execute(cmd.RootCmd)
|
||||
}
|
@ -4,15 +4,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
monkit "gopkg.in/spacemonkeygo/monkit.v2"
|
||||
|
||||
"storj.io/storj/pkg/process"
|
||||
"storj.io/storj/pkg/statdb"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := process.Main(process.ConfigEnvironment, &statdb.Service{})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
process.Exec(&cobra.Command{
|
||||
Use: "statdb",
|
||||
Short: "statdb",
|
||||
RunE: run,
|
||||
})
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
s := &statdb.Service{}
|
||||
s.SetLogger(zap.L())
|
||||
s.SetMetricHandler(monkit.Default)
|
||||
return s.Process(process.Ctx(cmd), cmd, args)
|
||||
}
|
||||
|
@ -1,31 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"storj.io/storj/pkg/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
root := &cobra.Command{
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
zap.S().Debugf("hello world")
|
||||
fmt.Println("hello world was logged to debug")
|
||||
},
|
||||
}
|
||||
|
||||
root.AddCommand(&cobra.Command{
|
||||
Use: "subcommand",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("yay")
|
||||
},
|
||||
})
|
||||
|
||||
process.Execute(root)
|
||||
}
|
@ -4,12 +4,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"storj.io/storj/pkg/process"
|
||||
"storj.io/storj/pkg/telemetry"
|
||||
@ -20,10 +18,15 @@ var (
|
||||
)
|
||||
|
||||
func main() {
|
||||
process.Must(process.Main(func() (*viper.Viper, error) { return nil, nil }, process.ServiceFunc(run)))
|
||||
process.Exec(&cobra.Command{
|
||||
Use: "metric-receiver",
|
||||
Short: "receive metrics",
|
||||
RunE: run,
|
||||
})
|
||||
}
|
||||
|
||||
func run(ctx context.Context, _ *cobra.Command, _ []string) error {
|
||||
func run(cmd *cobra.Command, args []string) (err error) {
|
||||
ctx := process.Ctx(cmd)
|
||||
s, err := telemetry.Listen(*addr)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -4,21 +4,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
|
||||
"storj.io/storj/pkg/process"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Set("metrics.interval", "1s")
|
||||
process.Must(process.Main(func() (*viper.Viper, error) { return nil, nil }, process.ServiceFunc(run)))
|
||||
process.Exec(&cobra.Command{
|
||||
Use: "metric-sender",
|
||||
Short: "send metrics",
|
||||
RunE: run,
|
||||
})
|
||||
}
|
||||
|
||||
func run(ctx context.Context, _ *cobra.Command, _ []string) error {
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
// just go to sleep and let the background telemetry start sending
|
||||
select {}
|
||||
}
|
||||
|
@ -5,14 +5,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/urfave/cli"
|
||||
"github.com/zeebo/errs"
|
||||
|
||||
@ -22,7 +20,7 @@ import (
|
||||
|
||||
var argError = errs.Class("argError")
|
||||
|
||||
func run(ctx context.Context, _ *cobra.Command, _ []string) error {
|
||||
func run(_ *cobra.Command, args []string) error {
|
||||
app := cli.NewApp()
|
||||
|
||||
app.Name = "Piece Store CLI"
|
||||
@ -142,9 +140,13 @@ func run(ctx context.Context, _ *cobra.Command, _ []string) error {
|
||||
sort.Sort(cli.FlagsByName(app.Flags))
|
||||
sort.Sort(cli.CommandsByName(app.Commands))
|
||||
|
||||
return app.Run(append([]string{os.Args[0]}, flag.Args()...))
|
||||
return app.Run(append([]string{os.Args[0]}, args...))
|
||||
}
|
||||
|
||||
func main() {
|
||||
process.Must(process.Main(func() (*viper.Viper, error) { return nil, nil }, process.ServiceFunc(run)))
|
||||
process.Exec(&cobra.Command{
|
||||
Use: "piecestore-cli",
|
||||
Short: "piecestore example cli",
|
||||
RunE: run,
|
||||
})
|
||||
}
|
||||
|
@ -4,81 +4,10 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
homedir "github.com/mitchellh/go-homedir"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ReadFlags will read in and bind flags for viper and pflag
|
||||
func readFlags() {
|
||||
pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
|
||||
pflag.Parse()
|
||||
err := viper.BindPFlags(pflag.CommandLine)
|
||||
if err != nil {
|
||||
log.Print("error parsing command line flags into viper:", err)
|
||||
}
|
||||
}
|
||||
|
||||
// get default config folder
|
||||
func configPath() string {
|
||||
home, _ := homedir.Dir()
|
||||
return filepath.Join(home, ".storj")
|
||||
}
|
||||
|
||||
// get default config file
|
||||
func defaultConfigFile(name string) string {
|
||||
return filepath.Join(configPath(), name)
|
||||
}
|
||||
|
||||
func generateConfig() error {
|
||||
err := viper.WriteConfigAs(defaultConfigFile("main.json"))
|
||||
return err
|
||||
}
|
||||
|
||||
// ConfigEnvironment will read in command line flags, set the name of the config file,
|
||||
// then look for configs in the current working directory and in $HOME/.storj
|
||||
func ConfigEnvironment() (*viper.Viper, error) {
|
||||
viper.SetEnvPrefix("storj")
|
||||
viper.AutomaticEnv()
|
||||
viper.SetConfigName("main")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath(configPath())
|
||||
|
||||
// Override default config with a specific config
|
||||
cfgFile := flag.String("config", "", "config file")
|
||||
generate := flag.Bool("generate", false, "generate a default config in ~/.storj")
|
||||
|
||||
// if that file exists, set it as the config instead of reading in from default locations
|
||||
if *cfgFile != "" && fileExists(*cfgFile) {
|
||||
viper.SetConfigFile(*cfgFile)
|
||||
}
|
||||
|
||||
err := viper.ReadInConfig()
|
||||
|
||||
if err != nil {
|
||||
log.Print("could not read config file; defaulting to command line flags for configuration")
|
||||
}
|
||||
|
||||
readFlags()
|
||||
|
||||
if *generate == true {
|
||||
err := generateConfig()
|
||||
if err != nil {
|
||||
log.Print("unable to generate config file.", err)
|
||||
}
|
||||
}
|
||||
|
||||
v := viper.GetViper()
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// check if file exists, handle error correctly if it doesn't
|
||||
func fileExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
@ -90,48 +19,3 @@ func fileExists(path string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Execute runs a *cobra.Command and sets up Storj-wide process configuration
|
||||
// like a configuration file and logging.
|
||||
func Execute(cmd *cobra.Command) {
|
||||
cobra.OnInitialize(func() {
|
||||
_, err := ConfigEnvironment()
|
||||
if err != nil {
|
||||
log.Fatal("error configuring environment", err)
|
||||
}
|
||||
})
|
||||
|
||||
Must(cmd.Execute())
|
||||
}
|
||||
|
||||
// Main runs a Service
|
||||
func Main(configFn func() (*viper.Viper, error), s ...Service) error {
|
||||
if _, err := configFn(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errors := make(chan error, len(s))
|
||||
|
||||
for _, service := range s {
|
||||
go func(ctx context.Context, s Service, ch <-chan error) {
|
||||
errors <- CtxService(s)(&cobra.Command{}, pflag.Args())
|
||||
}(ctx, service, errors)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case err := <-errors:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Must checks for errors
|
||||
func Must(err error) {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
@ -1,91 +0,0 @@
|
||||
// Copyright (C) 2018 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
package process_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"go.uber.org/zap"
|
||||
monkit "gopkg.in/spacemonkeygo/monkit.v2"
|
||||
"storj.io/storj/pkg/process"
|
||||
)
|
||||
|
||||
type MockedService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockedService) InstanceID() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *MockedService) Process(ctx context.Context, cmd *cobra.Command, args []string) error {
|
||||
arguments := m.Called(ctx, cmd, args)
|
||||
return arguments.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockedService) SetLogger(*zap.Logger) error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockedService) SetMetricHandler(*monkit.Registry) error {
|
||||
args := m.Called()
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func TestMainSingleProcess(t *testing.T) {
|
||||
mockService := new(MockedService)
|
||||
mockService.On("SetLogger", mock.Anything).Return(nil)
|
||||
mockService.On("SetMetricHandler", mock.Anything).Return(nil)
|
||||
mockService.On("Process", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
assert.Nil(t, process.Main(func() (*viper.Viper, error) { return nil, nil }, mockService))
|
||||
mockService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestMainMultipleProcess(t *testing.T) {
|
||||
// TODO: Fix the async issues in this test
|
||||
// mockService1 := MockedService{}
|
||||
// mockService2 := MockedService{}
|
||||
|
||||
// mockService1.On("SetLogger", mock.Anything).Return(nil)
|
||||
// mockService1.On("SetMetricHandler", mock.Anything).Return(nil)
|
||||
// mockService1.On("Process", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// mockService2.On("SetLogger", mock.Anything).Return(nil)
|
||||
// mockService2.On("SetMetricHandler", mock.Anything).Return(nil)
|
||||
// mockService2.On("Process", mock.Anything, mock.Anything, mock.Anything).Return(nil)
|
||||
|
||||
// assert.Nil(t, process.Main(func() error { return nil }, &mockService1, &mockService2))
|
||||
// mockService1.AssertExpectations(t)
|
||||
// mockService2.AssertExpectations(t)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
func TestMainProcessError(t *testing.T) {
|
||||
mockService := MockedService{}
|
||||
|
||||
err := process.ErrLogger.New("Process Error")
|
||||
mockService.On("SetLogger", mock.Anything).Return(nil)
|
||||
mockService.On("SetMetricHandler", mock.Anything).Return(nil)
|
||||
mockService.On("Process", mock.Anything, mock.Anything, mock.Anything).Return(err)
|
||||
assert.Equal(t, err, process.Main(func() (*viper.Viper, error) { return nil, nil }, &mockService))
|
||||
mockService.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestConfigEnvironment(t *testing.T) {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
func TestMust(t *testing.T) {
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
func TestExecute(t *testing.T) {
|
||||
t.Skip()
|
||||
}
|
@ -4,16 +4,9 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/zeebo/errs"
|
||||
"go.uber.org/zap"
|
||||
monkit "gopkg.in/spacemonkeygo/monkit.v2"
|
||||
|
||||
"storj.io/storj/pkg/telemetry"
|
||||
"storj.io/storj/pkg/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -22,109 +15,4 @@ var (
|
||||
|
||||
// Error is a process error class
|
||||
Error = errs.Class("proc error")
|
||||
|
||||
// ErrUsage is a process error class
|
||||
ErrUsage = errs.Class("usage error")
|
||||
|
||||
// ErrLogger Class
|
||||
ErrLogger = errs.Class("Logger Error")
|
||||
|
||||
// ErrMetricHandler Class
|
||||
ErrMetricHandler = errs.Class("Metric Handler Error")
|
||||
|
||||
//ErrProcess Class
|
||||
ErrProcess = errs.Class("Process Error")
|
||||
)
|
||||
|
||||
type idKey string
|
||||
|
||||
const (
|
||||
id idKey = "SrvID"
|
||||
)
|
||||
|
||||
// Service defines the interface contract for all Storj services
|
||||
type Service interface {
|
||||
// Process should run the program
|
||||
Process(ctx context.Context, cmd *cobra.Command, args []string) error
|
||||
|
||||
SetLogger(*zap.Logger) error
|
||||
SetMetricHandler(*monkit.Registry) error
|
||||
|
||||
// InstanceID should return a server or process instance identifier that is
|
||||
// stable across restarts, or the empty string to use the first non-nil
|
||||
// MAC address
|
||||
InstanceID() string
|
||||
}
|
||||
|
||||
// ServiceFunc allows one to implement a Service in terms of simply the Process
|
||||
// method
|
||||
type ServiceFunc func(ctx context.Context, cmd *cobra.Command,
|
||||
args []string) error
|
||||
|
||||
// Process implements the Service interface and simply calls f
|
||||
func (f ServiceFunc) Process(ctx context.Context, cmd *cobra.Command,
|
||||
args []string) error {
|
||||
return f(ctx, cmd, args)
|
||||
}
|
||||
|
||||
// SetLogger implements the Service interface but is a no-op
|
||||
func (f ServiceFunc) SetLogger(*zap.Logger) error { return nil }
|
||||
|
||||
// SetMetricHandler implements the Service interface but is a no-op
|
||||
func (f ServiceFunc) SetMetricHandler(*monkit.Registry) error { return nil }
|
||||
|
||||
// InstanceID implements the Service interface and expects default behavior
|
||||
func (f ServiceFunc) InstanceID() string { return "" }
|
||||
|
||||
// CtxRun is useful for generating cobra.Command.RunE methods that get
|
||||
// a context
|
||||
func CtxRun(fn func(ctx context.Context, cmd *cobra.Command,
|
||||
args []string) error) func(cmd *cobra.Command, args []string) error {
|
||||
return CtxService(ServiceFunc(fn))
|
||||
}
|
||||
|
||||
// CtxService turns a Service into a cobra.Command.RunE method
|
||||
func CtxService(s Service) func(cmd *cobra.Command, args []string) error {
|
||||
return func(cmd *cobra.Command, args []string) (err error) {
|
||||
instanceID := s.InstanceID()
|
||||
if instanceID == "" {
|
||||
instanceID = telemetry.DefaultInstanceID()
|
||||
}
|
||||
|
||||
ctx := context.WithValue(context.Background(), id, instanceID)
|
||||
|
||||
registry := monkit.Default
|
||||
scope := registry.ScopeNamed("process")
|
||||
defer scope.TaskNamed("main")(&ctx)(&err)
|
||||
|
||||
logger, err := utils.NewLogger(*logDisposition,
|
||||
zap.Fields(zap.String(string(id), instanceID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
defer zap.ReplaceGlobals(logger)()
|
||||
defer zap.RedirectStdLog(logger)()
|
||||
|
||||
if err := s.SetLogger(logger); err != nil {
|
||||
logger.Error("failed to configure logger", zap.Error(err))
|
||||
}
|
||||
|
||||
if err := s.SetMetricHandler(registry); err != nil {
|
||||
logger.Error("failed to configure metric handler", zap.Error(err))
|
||||
}
|
||||
|
||||
err = initMetrics(ctx, registry, instanceID)
|
||||
if err != nil {
|
||||
logger.Error("failed to configure telemetry", zap.Error(err))
|
||||
}
|
||||
|
||||
err = initDebug(logger, registry)
|
||||
if err != nil {
|
||||
logger.Error("failed to start debug endpoints", zap.Error(err))
|
||||
}
|
||||
|
||||
return s.Process(ctx, cmd, args)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user