Merge pull request #1580 from hashicorp/consul-dockerize-tests
Migrate Consul acceptance tests to Docker
This commit is contained in:
commit
ee2d32e5e0
|
@ -1,48 +1,106 @@
|
|||
package consul
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/consul/api"
|
||||
consulapi "github.com/hashicorp/consul/api"
|
||||
"github.com/hashicorp/vault/logical"
|
||||
logicaltest "github.com/hashicorp/vault/logical/testing"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"github.com/ory-am/dockertest"
|
||||
)
|
||||
|
||||
func TestBackend_config_access(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
var (
|
||||
testImagePull sync.Once
|
||||
)
|
||||
|
||||
func prepareTestContainer(t *testing.T, s logical.Storage, b logical.Backend) (cid dockertest.ContainerID, retAddress string) {
|
||||
if os.Getenv("CONSUL_ADDR") != "" {
|
||||
return "", os.Getenv("CONSUL_ADDR")
|
||||
}
|
||||
|
||||
accessConfig, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
// Without this the checks for whether the container has started seem to
|
||||
// never actually pass. There's really no reason to expose the test
|
||||
// containers, so don't.
|
||||
dockertest.BindDockerToLocalhost = "yep"
|
||||
|
||||
config := logical.TestBackendConfig()
|
||||
storage := &logical.InmemStorage{}
|
||||
config.StorageView = storage
|
||||
testImagePull.Do(func() {
|
||||
dockertest.Pull(dockertest.ConsulImageName)
|
||||
})
|
||||
|
||||
b := Backend()
|
||||
_, err := b.Setup(config)
|
||||
try := 0
|
||||
cid, connErr := dockertest.ConnectToConsul(60, 500*time.Millisecond, func(connAddress string) bool {
|
||||
try += 1
|
||||
// Build a client and verify that the credentials work
|
||||
config := consulapi.DefaultConfig()
|
||||
config.Address = connAddress
|
||||
config.Token = dockertest.ConsulACLMasterToken
|
||||
client, err := consulapi.NewClient(config)
|
||||
if err != nil {
|
||||
if try > 50 {
|
||||
panic(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
_, err = client.KV().Put(&consulapi.KVPair{
|
||||
Key: "setuptest",
|
||||
Value: []byte("setuptest"),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
if try > 50 {
|
||||
panic(err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
retAddress = connAddress
|
||||
return true
|
||||
})
|
||||
|
||||
if connErr != nil {
|
||||
t.Fatalf("could not connect to consul: %v", connErr)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func cleanupTestContainer(t *testing.T, cid dockertest.ContainerID) {
|
||||
err := cid.KillRemove()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackend_config_access(t *testing.T) {
|
||||
config := logical.TestBackendConfig()
|
||||
config.StorageView = &logical.InmemStorage{}
|
||||
b, err := Factory(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cid, connURL := prepareTestContainer(t, config.StorageView, b)
|
||||
if cid != "" {
|
||||
defer cleanupTestContainer(t, cid)
|
||||
}
|
||||
connData := map[string]interface{}{
|
||||
"address": connURL,
|
||||
"token": dockertest.ConsulACLMasterToken,
|
||||
}
|
||||
|
||||
confReq := &logical.Request{
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config/access",
|
||||
Storage: storage,
|
||||
Data: accessConfig,
|
||||
Storage: config.StorageView,
|
||||
Data: connData,
|
||||
}
|
||||
|
||||
resp, err := b.HandleRequest(confReq)
|
||||
|
@ -57,7 +115,7 @@ func TestBackend_config_access(t *testing.T) {
|
|||
}
|
||||
|
||||
expected := map[string]interface{}{
|
||||
"address": "127.0.0.1:8500",
|
||||
"address": connData["address"].(string),
|
||||
"scheme": "http",
|
||||
}
|
||||
if !reflect.DeepEqual(expected, resp.Data) {
|
||||
|
@ -69,45 +127,54 @@ func TestBackend_config_access(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBackend_basic(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
config := logical.TestBackendConfig()
|
||||
config.StorageView = &logical.InmemStorage{}
|
||||
b, err := Factory(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
cid, connURL := prepareTestContainer(t, config.StorageView, b)
|
||||
if cid != "" {
|
||||
defer cleanupTestContainer(t, cid)
|
||||
}
|
||||
connData := map[string]interface{}{
|
||||
"address": connURL,
|
||||
"token": dockertest.ConsulACLMasterToken,
|
||||
}
|
||||
|
||||
b, _ := Factory(logical.TestBackendConfig())
|
||||
logicaltest.Test(t, logicaltest.TestCase{
|
||||
AcceptanceTest: true,
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
Backend: b,
|
||||
Backend: b,
|
||||
Steps: []logicaltest.TestStep{
|
||||
testAccStepConfig(t, config),
|
||||
testAccStepConfig(t, connData),
|
||||
testAccStepWritePolicy(t, "test", testPolicy, ""),
|
||||
testAccStepReadToken(t, "test", config),
|
||||
testAccStepReadToken(t, "test", connData),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackend_renew_revoke(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
config := logical.TestBackendConfig()
|
||||
config.StorageView = &logical.InmemStorage{}
|
||||
b, err := Factory(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
|
||||
beConfig := logical.TestBackendConfig()
|
||||
beConfig.StorageView = &logical.InmemStorage{}
|
||||
b, _ := Factory(beConfig)
|
||||
cid, connURL := prepareTestContainer(t, config.StorageView, b)
|
||||
if cid != "" {
|
||||
defer cleanupTestContainer(t, cid)
|
||||
}
|
||||
connData := map[string]interface{}{
|
||||
"address": connURL,
|
||||
"token": dockertest.ConsulACLMasterToken,
|
||||
}
|
||||
|
||||
req := &logical.Request{
|
||||
Storage: beConfig.StorageView,
|
||||
Storage: config.StorageView,
|
||||
Operation: logical.UpdateOperation,
|
||||
Path: "config/access",
|
||||
Data: config,
|
||||
Data: connData,
|
||||
}
|
||||
resp, err := b.HandleRequest(req)
|
||||
if err != nil {
|
||||
|
@ -130,8 +197,11 @@ func TestBackend_renew_revoke(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || resp.IsError() {
|
||||
t.Fatal("resp nil or error")
|
||||
if resp == nil {
|
||||
t.Fatal("resp nil")
|
||||
}
|
||||
if resp.IsError() {
|
||||
t.Fatalf("resp is error: %v", resp.Error())
|
||||
}
|
||||
|
||||
generatedSecret := resp.Secret
|
||||
|
@ -147,16 +217,16 @@ func TestBackend_renew_revoke(t *testing.T) {
|
|||
log.Printf("[WARN] Generated token: %s", d.Token)
|
||||
|
||||
// Build a client and verify that the credentials work
|
||||
apiConfig := api.DefaultConfig()
|
||||
apiConfig.Address = config["address"].(string)
|
||||
apiConfig.Token = d.Token
|
||||
client, err := api.NewClient(apiConfig)
|
||||
consulapiConfig := consulapi.DefaultConfig()
|
||||
consulapiConfig.Address = connData["address"].(string)
|
||||
consulapiConfig.Token = d.Token
|
||||
client, err := consulapi.NewClient(consulapiConfig)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
log.Printf("[WARN] Verifying that the generated token works...")
|
||||
_, err = client.KV().Put(&api.KVPair{
|
||||
_, err = client.KV().Put(&consulapi.KVPair{
|
||||
Key: "foo",
|
||||
Value: []byte("bar"),
|
||||
}, nil)
|
||||
|
@ -181,7 +251,7 @@ func TestBackend_renew_revoke(t *testing.T) {
|
|||
}
|
||||
|
||||
log.Printf("[WARN] Verifying that the generated token does not work...")
|
||||
_, err = client.KV().Put(&api.KVPair{
|
||||
_, err = client.KV().Put(&consulapi.KVPair{
|
||||
Key: "foo",
|
||||
Value: []byte("bar"),
|
||||
}, nil)
|
||||
|
@ -191,41 +261,36 @@ func TestBackend_renew_revoke(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBackend_management(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
config := logical.TestBackendConfig()
|
||||
config.StorageView = &logical.InmemStorage{}
|
||||
b, err := Factory(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
config, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
cid, connURL := prepareTestContainer(t, config.StorageView, b)
|
||||
if cid != "" {
|
||||
defer cleanupTestContainer(t, cid)
|
||||
}
|
||||
connData := map[string]interface{}{
|
||||
"address": connURL,
|
||||
"token": dockertest.ConsulACLMasterToken,
|
||||
}
|
||||
|
||||
b, _ := Factory(logical.TestBackendConfig())
|
||||
logicaltest.Test(t, logicaltest.TestCase{
|
||||
AcceptanceTest: true,
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
Backend: b,
|
||||
Backend: b,
|
||||
Steps: []logicaltest.TestStep{
|
||||
testAccStepConfig(t, config),
|
||||
testAccStepConfig(t, connData),
|
||||
testAccStepWriteManagementPolicy(t, "test", ""),
|
||||
testAccStepReadManagementToken(t, "test", config),
|
||||
testAccStepReadManagementToken(t, "test", connData),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackend_crud(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
}
|
||||
|
||||
_, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
|
||||
b, _ := Factory(logical.TestBackendConfig())
|
||||
logicaltest.Test(t, logicaltest.TestCase{
|
||||
AcceptanceTest: true,
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
Backend: b,
|
||||
Backend: b,
|
||||
Steps: []logicaltest.TestStep{
|
||||
testAccStepWritePolicy(t, "test", testPolicy, ""),
|
||||
testAccStepReadPolicy(t, "test", testPolicy, 0),
|
||||
|
@ -235,19 +300,9 @@ func TestBackend_crud(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBackend_role_lease(t *testing.T) {
|
||||
if os.Getenv(logicaltest.TestEnvVar) == "" {
|
||||
t.Skip(fmt.Sprintf("Acceptance tests skipped unless env '%s' set", logicaltest.TestEnvVar))
|
||||
return
|
||||
}
|
||||
|
||||
_, process := testStartConsulServer(t)
|
||||
defer testStopConsulServer(t, process)
|
||||
|
||||
b, _ := Factory(logical.TestBackendConfig())
|
||||
logicaltest.Test(t, logicaltest.TestCase{
|
||||
AcceptanceTest: true,
|
||||
PreCheck: func() { testAccPreCheck(t) },
|
||||
Backend: b,
|
||||
Backend: b,
|
||||
Steps: []logicaltest.TestStep{
|
||||
testAccStepWritePolicy(t, "test", testPolicy, "6h"),
|
||||
testAccStepReadPolicy(t, "test", testPolicy, 6*time.Hour),
|
||||
|
@ -256,74 +311,6 @@ func TestBackend_role_lease(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func testStartConsulServer(t *testing.T) (map[string]interface{}, *os.Process) {
|
||||
if _, err := exec.LookPath("consul"); err != nil {
|
||||
t.Errorf("consul not found: %s", err)
|
||||
}
|
||||
|
||||
td, err := ioutil.TempDir("", "vault")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
|
||||
tf, err := ioutil.TempFile("", "vault")
|
||||
if err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
if _, err := tf.Write([]byte(strings.TrimSpace(testConsulConfig))); err != nil {
|
||||
t.Fatalf("err: %s", err)
|
||||
}
|
||||
tf.Close()
|
||||
|
||||
cmd := exec.Command(
|
||||
"consul", "agent",
|
||||
"-server",
|
||||
"-bootstrap",
|
||||
"-advertise", "127.0.0.1",
|
||||
"-config-file", tf.Name(),
|
||||
"-data-dir", td)
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
stdoutScanner := bufio.NewScanner(stdout)
|
||||
stderrScanner := bufio.NewScanner(stderr)
|
||||
stdoutScanFunc := func() {
|
||||
for stdoutScanner.Scan() {
|
||||
t.Logf("Consul stdout: %s\n", stdoutScanner.Text())
|
||||
}
|
||||
}
|
||||
stderrScanFunc := func() {
|
||||
for stderrScanner.Scan() {
|
||||
t.Logf("Consul stderr: %s\n", stderrScanner.Text())
|
||||
}
|
||||
}
|
||||
if os.Getenv("VAULT_VERBOSE_ACC_TESTS") != "" {
|
||||
go stdoutScanFunc()
|
||||
go stderrScanFunc()
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("error starting Consul: %s", err)
|
||||
}
|
||||
// Give Consul time to startup
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
config := map[string]interface{}{
|
||||
"address": "127.0.0.1:8500",
|
||||
"token": "test",
|
||||
}
|
||||
return config, cmd.Process
|
||||
}
|
||||
|
||||
func testStopConsulServer(t *testing.T, p *os.Process) {
|
||||
p.Kill()
|
||||
}
|
||||
|
||||
func testAccPreCheck(t *testing.T) {
|
||||
if _, err := exec.LookPath("consul"); err != nil {
|
||||
t.Fatal("consul must be on PATH")
|
||||
}
|
||||
}
|
||||
|
||||
func testAccStepConfig(
|
||||
t *testing.T, config map[string]interface{}) logicaltest.TestStep {
|
||||
return logicaltest.TestStep{
|
||||
|
@ -348,16 +335,16 @@ func testAccStepReadToken(
|
|||
log.Printf("[WARN] Generated token: %s", d.Token)
|
||||
|
||||
// Build a client and verify that the credentials work
|
||||
config := api.DefaultConfig()
|
||||
config := consulapi.DefaultConfig()
|
||||
config.Address = conf["address"].(string)
|
||||
config.Token = d.Token
|
||||
client, err := api.NewClient(config)
|
||||
client, err := consulapi.NewClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[WARN] Verifying that the generated token works...")
|
||||
_, err = client.KV().Put(&api.KVPair{
|
||||
_, err = client.KV().Put(&consulapi.KVPair{
|
||||
Key: "foo",
|
||||
Value: []byte("bar"),
|
||||
}, nil)
|
||||
|
@ -385,16 +372,16 @@ func testAccStepReadManagementToken(
|
|||
log.Printf("[WARN] Generated token: %s", d.Token)
|
||||
|
||||
// Build a client and verify that the credentials work
|
||||
config := api.DefaultConfig()
|
||||
config := consulapi.DefaultConfig()
|
||||
config.Address = conf["address"].(string)
|
||||
config.Token = d.Token
|
||||
client, err := api.NewClient(config)
|
||||
client, err := consulapi.NewClient(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("[WARN] Verifying that the generated token works...")
|
||||
_, _, err = client.ACL().Create(&api.ACLEntry{
|
||||
_, _, err = client.ACL().Create(&consulapi.ACLEntry{
|
||||
Type: "management",
|
||||
Name: "test2",
|
||||
}, nil)
|
||||
|
@ -468,11 +455,3 @@ key "" {
|
|||
policy = "write"
|
||||
}
|
||||
`
|
||||
|
||||
const testConsulConfig = `
|
||||
{
|
||||
"datacenter": "test",
|
||||
"acl_datacenter": "test",
|
||||
"acl_master_token": "test"
|
||||
}
|
||||
`
|
||||
|
|
99
vendor/github.com/ory-am/dockertest/consul.go
generated
vendored
Normal file
99
vendor/github.com/ory-am/dockertest/consul.go
generated
vendored
Normal file
|
@ -0,0 +1,99 @@
|
|||
package dockertest
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ConsulDatacenter must be defined when starting a Consul datacenter; this
|
||||
// value will be used for both the datacenter and the ACL datacenter
|
||||
ConsulDatacenter = "test"
|
||||
|
||||
// ConsulACLDefaultPolicy defines the default policy to use with Consul ACLs
|
||||
ConsulACLDefaultPolicy = "deny"
|
||||
|
||||
// ConsulACLMasterToken defines the master ACL token
|
||||
ConsulACLMasterToken = "test"
|
||||
|
||||
// A function with no arguments that outputs a valid JSON string to be used
|
||||
// as the value of the environment variable CONSUL_LOCAL_CONFIG.
|
||||
ConsulLocalConfigGen = DefaultConsulLocalConfig
|
||||
)
|
||||
|
||||
func DefaultConsulLocalConfig() (string, error) {
|
||||
type d struct {
|
||||
Datacenter string `json:"datacenter,omitempty"`
|
||||
ACLDatacenter string `json:"acl_datacenter,omitempty"`
|
||||
ACLDefaultPolicy string `json:"acl_default_policy,omitempty"`
|
||||
ACLMasterToken string `json:"acl_master_token,omitempty"`
|
||||
}
|
||||
|
||||
vals := &d{
|
||||
Datacenter: ConsulDatacenter,
|
||||
ACLDatacenter: ConsulDatacenter,
|
||||
ACLDefaultPolicy: ConsulACLDefaultPolicy,
|
||||
ACLMasterToken: ConsulACLMasterToken,
|
||||
}
|
||||
|
||||
ret, err := json.Marshal(vals)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return string(ret), nil
|
||||
}
|
||||
|
||||
// SetupConsulContainer sets up a real Consul instance for testing purposes,
|
||||
// using a Docker container. It returns the container ID and its IP address,
|
||||
// or makes the test fail on error.
|
||||
func SetupConsulContainer() (c ContainerID, ip string, port int, err error) {
|
||||
port = RandomPort()
|
||||
forward := fmt.Sprintf("%d:%d", port, 8500)
|
||||
advertise := ip
|
||||
if BindDockerToLocalhost != "" {
|
||||
advertise = "127.0.0.1"
|
||||
forward = advertise + ":" + forward
|
||||
}
|
||||
localConfig, err := ConsulLocalConfigGen()
|
||||
if err != nil {
|
||||
return "", "", 0, err
|
||||
}
|
||||
c, ip, err = SetupContainer(ConsulImageName, port, 10*time.Second, func() (string, error) {
|
||||
return run(
|
||||
"--name", GenerateContainerID(),
|
||||
"-d",
|
||||
"-p", forward,
|
||||
"-e", fmt.Sprintf("CONSUL_LOCAL_CONFIG=%s", localConfig),
|
||||
ConsulImageName,
|
||||
"agent",
|
||||
"-server", // Run in server mode
|
||||
"-bootstrap-expect", "1", // Only a single server
|
||||
"-advertise", advertise,
|
||||
"-client", "0.0.0.0", // Allow clients from any IP, otherwise the bridge IP will be where clients come from and it will be rejected
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ConnectToConsul starts a Consul image and passes the address to the
|
||||
// connector callback function.
|
||||
func ConnectToConsul(tries int, delay time.Duration, connector func(address string) bool) (c ContainerID, err error) {
|
||||
c, ip, port, err := SetupConsulContainer()
|
||||
if err != nil {
|
||||
return c, fmt.Errorf("Could not set up Consul container: %v", err)
|
||||
}
|
||||
|
||||
for try := 0; try <= tries; try++ {
|
||||
time.Sleep(delay)
|
||||
address := fmt.Sprintf("%s:%d", ip, port)
|
||||
if connector(address) {
|
||||
return c, nil
|
||||
}
|
||||
log.Printf("Try %d failed. Retrying.", try)
|
||||
}
|
||||
return c, errors.New("Could not set up Consul container.")
|
||||
}
|
3
vendor/github.com/ory-am/dockertest/vars.go
generated
vendored
3
vendor/github.com/ory-am/dockertest/vars.go
generated
vendored
|
@ -54,6 +54,9 @@ var (
|
|||
|
||||
// MockserverImageName name is the Mockserver image name on dockerhub.
|
||||
MockserverImageName = env.Getenv("DOCKERTEST_MOCKSERVER_IMAGE_NAME", "jamesdbloom/mockserver")
|
||||
|
||||
// ConsulImageName is the Consul image name on dockerhub.
|
||||
ConsulImageName = env.Getenv("DOCKERTEST_CONSUL_IMAGE_NAME", "consul")
|
||||
)
|
||||
|
||||
// Username and password configuration
|
||||
|
|
Loading…
Reference in a new issue