Merge pull request #257 from RouxAntoine/feature/add-get-token-v2
[feature] add command get k3stoken to retrieve cluster token (thanks @RouxAntoine)
This commit is contained in:
commit
92c11570a3
4
.gitignore
vendored
4
.gitignore
vendored
@ -18,4 +18,6 @@ site/
|
||||
|
||||
# Editors
|
||||
.vscode/
|
||||
.local/
|
||||
.local/
|
||||
.idea/
|
||||
*.iml
|
||||
6
Makefile
6
Makefile
@ -33,6 +33,7 @@ TAGS :=
|
||||
TESTS := .
|
||||
TESTFLAGS :=
|
||||
LDFLAGS := -w -s -X github.com/rancher/k3d/version.Version=${GIT_TAG} -X github.com/rancher/k3d/version.K3sVersion=${K3S_TAG}
|
||||
GCFLAGS :=
|
||||
GOFLAGS :=
|
||||
BINDIR := $(CURDIR)/bin
|
||||
BINARIES := k3d
|
||||
@ -68,8 +69,11 @@ LINT_DIRS := $(DIRS) $(foreach dir,$(REC_DIRS),$(dir)/...)
|
||||
|
||||
all: clean fmt check build
|
||||
|
||||
build-debug: GCFLAGS+="all=-N -l"
|
||||
build-debug: build
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o '$(BINDIR)/$(BINARIES)'
|
||||
CGO_ENABLED=0 $(GO) build $(GOFLAGS) -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -gcflags '$(GCFLAGS)' -o '$(BINDIR)/$(BINARIES)'
|
||||
|
||||
build-cross: LDFLAGS += -extldflags "-static"
|
||||
build-cross:
|
||||
|
||||
@ -115,7 +115,7 @@ func NewCmdCreateCluster() *cobra.Command {
|
||||
cmd.Flags().IntP("workers", "w", 0, "Specify how many workers you want to create")
|
||||
cmd.Flags().StringP("image", "i", fmt.Sprintf("%s:%s", k3d.DefaultK3sImageRepo, version.GetK3sVersion(false)), "Specify k3s image that you want to use for the nodes")
|
||||
cmd.Flags().String("network", "", "Join an existing network")
|
||||
cmd.Flags().String("secret", "", "Specify a cluster secret. By default, we generate one.")
|
||||
cmd.Flags().String("token", "", "Specify a cluster token. By default, we generate one.")
|
||||
cmd.Flags().StringArrayP("volume", "v", nil, "Mount volumes into the nodes (Format: `--volume [SOURCE:]DEST[@NODEFILTER[;NODEFILTER...]]`\n - Example: `k3d create -w 2 -v /my/path@worker[0,1] -v /tmp/test:/tmp/other@master[0]`")
|
||||
cmd.Flags().StringArrayP("port", "p", nil, "Map ports from the node containers to the host (Format: `[HOST:][HOSTPORT:]CONTAINERPORT[/PROTOCOL][@NODEFILTER]`)\n - Example: `k3d create -w 2 -p 8080:80@worker[0] -p 8081@worker[1]`")
|
||||
cmd.Flags().BoolVar(&createClusterOpts.WaitForMaster, "wait", false, "Wait for the master(s) to be ready before returning. Use '--timeout DURATION' to not wait forever.")
|
||||
@ -206,8 +206,8 @@ func parseCreateClusterCmd(cmd *cobra.Command, args []string, createClusterOpts
|
||||
log.Fatalln("Can only run a single node in hostnetwork mode")
|
||||
}
|
||||
|
||||
// --secret
|
||||
secret, err := cmd.Flags().GetString("secret")
|
||||
// --token
|
||||
token, err := cmd.Flags().GetString("token")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
@ -309,7 +309,7 @@ func parseCreateClusterCmd(cmd *cobra.Command, args []string, createClusterOpts
|
||||
cluster := &k3d.Cluster{
|
||||
Name: clustername,
|
||||
Network: network,
|
||||
Secret: secret,
|
||||
Token: token,
|
||||
CreateClusterOpts: createClusterOpts,
|
||||
ExposeAPI: exposeAPI,
|
||||
}
|
||||
|
||||
@ -22,9 +22,9 @@ THE SOFTWARE.
|
||||
package get
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
k3cluster "github.com/rancher/k3d/pkg/cluster"
|
||||
@ -37,41 +37,32 @@ import (
|
||||
"github.com/liggitt/tabwriter"
|
||||
)
|
||||
|
||||
// TODO : deal with --all flag to manage differentiate started cluster and stopped cluster like `docker ps` and `docker ps -a`
|
||||
type clusterFlags struct {
|
||||
noHeader bool
|
||||
token bool
|
||||
}
|
||||
|
||||
// NewCmdGetCluster returns a new cobra command
|
||||
func NewCmdGetCluster() *cobra.Command {
|
||||
|
||||
clusterFlags := clusterFlags{}
|
||||
|
||||
// create new command
|
||||
cmd := &cobra.Command{
|
||||
Use: "cluster [NAME [NAME...]]",
|
||||
Aliases: []string{"clusters"},
|
||||
Short: "Get cluster(s)",
|
||||
Long: `Get cluster(s).`,
|
||||
Args: cobra.MinimumNArgs(0), // 0 or more; 0 = all
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
clusters, headersOff := parseGetClusterCmd(cmd, args)
|
||||
var existingClusters []*k3d.Cluster
|
||||
if len(clusters) == 0 { // Option a) no cluster name specified -> get all clusters
|
||||
found, err := k3cluster.GetClusters(cmd.Context(), runtimes.SelectedRuntime)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
existingClusters = append(existingClusters, found...)
|
||||
} else { // Option b) cluster name specified -> get specific cluster
|
||||
for _, cluster := range clusters {
|
||||
found, err := k3cluster.GetCluster(cmd.Context(), runtimes.SelectedRuntime, cluster)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
existingClusters = append(existingClusters, found)
|
||||
}
|
||||
}
|
||||
// print existing clusters
|
||||
printClusters(existingClusters, headersOff)
|
||||
clusters := buildClusterList(cmd.Context(), args)
|
||||
PrintClusters(clusters, clusterFlags)
|
||||
},
|
||||
}
|
||||
|
||||
// add flags
|
||||
cmd.Flags().Bool("no-headers", false, "Disable headers")
|
||||
cmd.Flags().BoolVar(&clusterFlags.noHeader, "no-headers", false, "Disable headers")
|
||||
cmd.Flags().BoolVar(&clusterFlags.token, "token", false, "Print k3s cluster token")
|
||||
|
||||
// add subcommands
|
||||
|
||||
@ -79,54 +70,57 @@ func NewCmdGetCluster() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func parseGetClusterCmd(cmd *cobra.Command, args []string) ([]*k3d.Cluster, bool) {
|
||||
func buildClusterList(ctx context.Context, args []string) []*k3d.Cluster {
|
||||
var clusters []*k3d.Cluster
|
||||
var err error
|
||||
|
||||
// --no-headers
|
||||
headersOff, err := cmd.Flags().GetBool("no-headers")
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
|
||||
// Args = cluster name
|
||||
if len(args) == 0 {
|
||||
return nil, headersOff
|
||||
// cluster name not specified : get all clusters
|
||||
clusters, err = k3cluster.GetClusters(ctx, runtimes.SelectedRuntime)
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
} else {
|
||||
for _, clusterName := range args {
|
||||
// cluster name specified : get specific cluster
|
||||
retrievedCluster, err := k3cluster.GetCluster(ctx, runtimes.SelectedRuntime, &k3d.Cluster{Name: clusterName})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
clusters = append(clusters, retrievedCluster)
|
||||
}
|
||||
}
|
||||
|
||||
clusters := []*k3d.Cluster{}
|
||||
for _, name := range args {
|
||||
clusters = append(clusters, &k3d.Cluster{Name: name})
|
||||
}
|
||||
|
||||
return clusters, headersOff
|
||||
return clusters
|
||||
}
|
||||
|
||||
func printClusters(clusters []*k3d.Cluster, headersOff bool) {
|
||||
// PrintPrintClusters : display list of cluster
|
||||
func PrintClusters(clusters []*k3d.Cluster, flags clusterFlags) {
|
||||
|
||||
tabwriter := tabwriter.NewWriter(os.Stdout, 6, 4, 3, ' ', tabwriter.RememberWidths)
|
||||
defer tabwriter.Flush()
|
||||
|
||||
if !headersOff {
|
||||
if !flags.noHeader {
|
||||
headers := []string{"NAME", "MASTERS", "WORKERS"} // TODO: getCluster: add status column
|
||||
if flags.token {
|
||||
headers = append(headers, "TOKEN")
|
||||
}
|
||||
_, err := fmt.Fprintf(tabwriter, "%s\n", strings.Join(headers, "\t"))
|
||||
if err != nil {
|
||||
log.Fatalln("Failed to print headers")
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(clusters, func(i, j int) bool {
|
||||
return clusters[i].Name < clusters[j].Name
|
||||
})
|
||||
k3cluster.SortClusters(clusters)
|
||||
|
||||
for _, cluster := range clusters {
|
||||
masterCount := 0
|
||||
workerCount := 0
|
||||
for _, node := range cluster.Nodes {
|
||||
if node.Role == k3d.MasterRole {
|
||||
masterCount++
|
||||
} else if node.Role == k3d.WorkerRole {
|
||||
workerCount++
|
||||
}
|
||||
masterCount := cluster.MasterCount()
|
||||
workerCount := cluster.WorkerCount()
|
||||
|
||||
if flags.token {
|
||||
fmt.Fprintf(tabwriter, "%s\t%d\t%d\t%s\n", cluster.Name, masterCount, workerCount, cluster.Token)
|
||||
} else {
|
||||
fmt.Fprintf(tabwriter, "%s\t%d\t%d\n", cluster.Name, masterCount, workerCount)
|
||||
}
|
||||
fmt.Fprintf(tabwriter, "%s\t%d\t%d\n", cluster.Name, masterCount, workerCount)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +69,11 @@ func NewCmdGetKubeconfig() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
for _, clusterName := range args {
|
||||
clusters = append(clusters, &k3d.Cluster{Name: clusterName})
|
||||
retrievedCluster, err := cluster.GetCluster(cmd.Context(), runtimes.SelectedRuntime, &k3d.Cluster{Name: clusterName})
|
||||
if err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
clusters = append(clusters, retrievedCluster)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -65,6 +65,10 @@ All Nodes of a k3d cluster are part of the same docker network.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if flags.version {
|
||||
printVersion()
|
||||
} else {
|
||||
if err := cmd.Usage(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
@ -73,8 +77,7 @@ All Nodes of a k3d cluster are part of the same docker network.`,
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
log.Errorln(err)
|
||||
os.Exit(1)
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,7 +14,7 @@ k3d
|
||||
--network # specify a network you want to connect to
|
||||
--no-image-volume # disable the creation of a volume for storing images (used for the 'k3d load image' command)
|
||||
-p, --port # add some more port mappings
|
||||
--secret # specify a cluster secret (default: auto-generated)
|
||||
--token # specify a cluster token (default: auto-generated)
|
||||
--timeout # specify a timeout, after which the cluster creation will be interrupted and changes rolled back
|
||||
--update-kubeconfig # enable the automated update of the default kubeconfig with the details of the newly created cluster (also sets '--wait=true')
|
||||
--switch # (implies --update-kubeconfig) automatically sets the current-context of your default kubeconfig to the new cluster's context
|
||||
|
||||
@ -25,6 +25,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@ -79,19 +80,19 @@ func CreateCluster(ctx context.Context, runtime k3drt.Runtime, cluster *k3d.Clus
|
||||
}
|
||||
cluster.Network.Name = networkID
|
||||
extraLabels := map[string]string{
|
||||
"k3d.cluster.network": networkID,
|
||||
"k3d.cluster.network.external": strconv.FormatBool(cluster.Network.External),
|
||||
k3d.LabelNetwork: networkID,
|
||||
k3d.LabelNetworkExternal: strconv.FormatBool(cluster.Network.External),
|
||||
}
|
||||
if networkExists {
|
||||
extraLabels["k3d.cluster.network.external"] = "true" // if the network wasn't created, we say that it's managed externally (important for cluster deletion)
|
||||
extraLabels[k3d.LabelNetworkExternal] = "true" // if the network wasn't created, we say that it's managed externally (important for cluster deletion)
|
||||
}
|
||||
|
||||
/*
|
||||
* Cluster Secret
|
||||
* Cluster Token
|
||||
*/
|
||||
|
||||
if cluster.Secret == "" {
|
||||
cluster.Secret = GenerateClusterSecret()
|
||||
if cluster.Token == "" {
|
||||
cluster.Token = GenerateClusterToken()
|
||||
}
|
||||
|
||||
/*
|
||||
@ -105,7 +106,7 @@ func CreateCluster(ctx context.Context, runtime k3drt.Runtime, cluster *k3d.Clus
|
||||
return err
|
||||
}
|
||||
|
||||
extraLabels["k3d.cluster.imageVolume"] = imageVolumeName
|
||||
extraLabels[k3d.LabelImageVolume] = imageVolumeName
|
||||
|
||||
// attach volume to nodes
|
||||
for _, node := range cluster.Nodes {
|
||||
@ -127,8 +128,8 @@ func CreateCluster(ctx context.Context, runtime k3drt.Runtime, cluster *k3d.Clus
|
||||
node.Labels = make(map[string]string) // TODO: maybe create an init function?
|
||||
}
|
||||
node.Labels["k3d.cluster"] = cluster.Name
|
||||
node.Env = append(node.Env, fmt.Sprintf("K3S_TOKEN=%s", cluster.Secret))
|
||||
node.Labels["k3d.cluster.secret"] = cluster.Secret
|
||||
node.Env = append(node.Env, fmt.Sprintf("K3S_TOKEN=%s", cluster.Token))
|
||||
node.Labels[k3d.LabelToken] = cluster.Token
|
||||
node.Labels["k3d.cluster.url"] = connectionURL
|
||||
|
||||
// append extra labels
|
||||
@ -419,7 +420,7 @@ func populateClusterFieldsFromLabels(cluster *k3d.Cluster) error {
|
||||
|
||||
// get the name of the cluster network
|
||||
if cluster.Network.Name == "" {
|
||||
if networkName, ok := node.Labels["k3d.cluster.network"]; ok {
|
||||
if networkName, ok := node.Labels[k3d.LabelNetwork]; ok {
|
||||
cluster.Network.Name = networkName
|
||||
}
|
||||
}
|
||||
@ -427,7 +428,7 @@ func populateClusterFieldsFromLabels(cluster *k3d.Cluster) error {
|
||||
// check if the network is external
|
||||
// since the struct value is a bool, initialized as false, we cannot check if it's unset
|
||||
if !cluster.Network.External && !networkExternalSet {
|
||||
if networkExternalString, ok := node.Labels["k3d.cluster.network.external"]; ok {
|
||||
if networkExternalString, ok := node.Labels[k3d.LabelNetworkExternal]; ok {
|
||||
if networkExternal, err := strconv.ParseBool(networkExternalString); err == nil {
|
||||
cluster.Network.External = networkExternal
|
||||
networkExternalSet = true
|
||||
@ -437,11 +438,17 @@ func populateClusterFieldsFromLabels(cluster *k3d.Cluster) error {
|
||||
|
||||
// get image volume // TODO: enable external image volumes the same way we do it with networks
|
||||
if cluster.ImageVolume == "" {
|
||||
if imageVolumeName, ok := node.Labels["k3d.cluster.imageVolume"]; ok {
|
||||
if imageVolumeName, ok := node.Labels[k3d.LabelImageVolume]; ok {
|
||||
cluster.ImageVolume = imageVolumeName
|
||||
}
|
||||
}
|
||||
|
||||
// get k3s cluster's token
|
||||
if cluster.Token == "" {
|
||||
if token, ok := node.Labels[k3d.LabelToken]; ok {
|
||||
cluster.Token = token
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -487,8 +494,8 @@ func GetCluster(ctx context.Context, runtime k3drt.Runtime, cluster *k3d.Cluster
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
// GenerateClusterSecret generates a random 20 character string
|
||||
func GenerateClusterSecret() string {
|
||||
// GenerateClusterToken generates a random 20 character string
|
||||
func GenerateClusterToken() string {
|
||||
return util.GenerateRandomString(20)
|
||||
}
|
||||
|
||||
@ -585,3 +592,11 @@ func StopCluster(ctx context.Context, runtime k3drt.Runtime, cluster *k3d.Cluste
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortClusters : in place sort cluster list by cluster name alphabetical order
|
||||
func SortClusters(clusters []*k3d.Cluster) []*k3d.Cluster {
|
||||
sort.Slice(clusters, func(i, j int) bool {
|
||||
return clusters[i].Name < clusters[j].Name
|
||||
})
|
||||
return clusters
|
||||
}
|
||||
@ -50,7 +50,7 @@ func LoadImagesIntoCluster(ctx context.Context, runtime runtimes.Runtime, images
|
||||
var ok bool
|
||||
for _, node := range cluster.Nodes {
|
||||
if node.Role == k3d.MasterRole || node.Role == k3d.WorkerRole {
|
||||
if imageVolume, ok = node.Labels["k3d.cluster.imageVolume"]; ok {
|
||||
if imageVolume, ok = node.Labels[k3d.LabelImageVolume]; ok {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,6 +74,14 @@ var DefaultObjectLabels = map[string]string{
|
||||
"app": "k3d",
|
||||
}
|
||||
|
||||
// List of k3d technical label name
|
||||
const (
|
||||
LabelToken string = "k3d.cluster.token"
|
||||
LabelImageVolume string = "k3d.cluster.imageVolume"
|
||||
LabelNetworkExternal string = "k3d.cluster.network.external"
|
||||
LabelNetwork string = "k3d.cluster.network"
|
||||
)
|
||||
|
||||
// DefaultRoleCmds maps the node roles to their respective default commands
|
||||
var DefaultRoleCmds = map[Role][]string{
|
||||
MasterRole: {"server"},
|
||||
@ -152,7 +160,7 @@ type ClusterNetwork struct {
|
||||
type Cluster struct {
|
||||
Name string `yaml:"name" json:"name,omitempty"`
|
||||
Network ClusterNetwork `yaml:"network" json:"network,omitempty"`
|
||||
Secret string `yaml:"cluster_secret" json:"clusterSecret,omitempty"`
|
||||
Token string `yaml:"cluster_token" json:"clusterToken,omitempty"`
|
||||
Nodes []*Node `yaml:"nodes" json:"nodes,omitempty"`
|
||||
InitNode *Node // init master node
|
||||
ExternalDatastore ExternalDatastore `yaml:"external_datastore" json:"externalDatastore,omitempty"`
|
||||
@ -162,6 +170,27 @@ type Cluster struct {
|
||||
ImageVolume string `yaml:"image_volume" json:"imageVolume,omitempty"`
|
||||
}
|
||||
|
||||
// MasterCount return number of master node into cluster
|
||||
func (c *Cluster) MasterCount() int {
|
||||
masterCount := 0
|
||||
for _, node := range c.Nodes {
|
||||
if node.Role == MasterRole {
|
||||
masterCount++
|
||||
}
|
||||
}
|
||||
return masterCount
|
||||
}
|
||||
// WorkerCount return number of worker node into cluster
|
||||
func (c *Cluster) WorkerCount() int {
|
||||
workerCount := 0
|
||||
for _, node := range c.Nodes {
|
||||
if node.Role == WorkerRole {
|
||||
workerCount++
|
||||
}
|
||||
}
|
||||
return workerCount
|
||||
}
|
||||
|
||||
// Node describes a k3d node
|
||||
type Node struct {
|
||||
Name string `yaml:"name" json:"name,omitempty"`
|
||||
|
||||
@ -43,7 +43,7 @@ func GetConfigDirOrCreate() (string, error) {
|
||||
|
||||
// create directories if necessary
|
||||
if err := createDirIfNotExists(configDir); err != nil {
|
||||
log.Errorln("Failed to create config path '%s'", configDir)
|
||||
log.Errorf("Failed to create config path '%s'", configDir)
|
||||
return "", err
|
||||
}
|
||||
|
||||
|
||||
@ -37,7 +37,7 @@ const (
|
||||
var src = rand.NewSource(time.Now().UnixNano())
|
||||
|
||||
// GenerateRandomString thanks to https://stackoverflow.com/a/31832326/6450189
|
||||
// GenerateRandomString is used to generate a random string that is used as a cluster secret
|
||||
// GenerateRandomString is used to generate a random string that is used as a cluster token
|
||||
func GenerateRandomString(n int) string {
|
||||
|
||||
sb := strings.Builder{}
|
||||
|
||||
@ -107,3 +107,8 @@ check_registry() {
|
||||
check_volume_exists() {
|
||||
docker volume inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
check_cluster_token_exist() {
|
||||
[ -n "$EXE" ] || abort "EXE is not defined"
|
||||
$EXE get cluster "$1" --token | grep "TOKEN" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
@ -16,6 +16,10 @@ check_cluster_count 2
|
||||
info "Checking we have access to both clusters..."
|
||||
check_clusters "c1" "c2" || failed "error checking cluster"
|
||||
|
||||
info "Check k3s token retrieval"
|
||||
check_cluster_token_exist "c1" || failed "could not find cluster token c1"
|
||||
check_cluster_token_exist "c2" || failed "could not find cluster token c2"
|
||||
|
||||
info "Deleting clusters..."
|
||||
$EXE delete cluster c1 || failed "could not delete the cluster c1"
|
||||
$EXE delete cluster c2 || failed "could not delete the cluster c2"
|
||||
|
||||
@ -40,7 +40,7 @@ check_multi_node "$clustername" 2 || failed "failed to verify number of nodes"
|
||||
|
||||
# 4. adding another worker node
|
||||
info "Adding one worker node..."
|
||||
LOG_LEVEL=debug $EXE create node "extra-worker" --cluster "$clustername" --role "worker" --wait --timeout 360s || failed "failed to add worker node"
|
||||
$EXE create node "extra-worker" --cluster "$clustername" --role "worker" --wait --timeout 360s || failed "failed to add worker node"
|
||||
|
||||
info "Checking that we have 3 nodes available now..."
|
||||
check_multi_node "$clustername" 3 || failed "failed to verify number of nodes"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user