2020-05-01 22:45:15 +00:00
|
|
|
package config
|
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"io/ioutil"
|
2020-08-17 21:24:49 +00:00
|
|
|
"net"
|
2020-05-01 22:45:15 +00:00
|
|
|
"os"
|
|
|
|
"path/filepath"
|
2020-08-17 21:24:49 +00:00
|
|
|
"strings"
|
2020-05-01 22:45:15 +00:00
|
|
|
"testing"
|
2020-08-12 16:35:30 +00:00
|
|
|
"time"
|
2020-05-01 22:45:15 +00:00
|
|
|
|
2020-12-21 18:46:41 +00:00
|
|
|
"github.com/stretchr/testify/assert"
|
2020-05-01 22:45:15 +00:00
|
|
|
"github.com/stretchr/testify/require"
|
2022-03-24 19:32:25 +00:00
|
|
|
|
|
|
|
"github.com/hashicorp/consul/types"
|
2020-05-01 22:45:15 +00:00
|
|
|
)
|
|
|
|
|
2020-08-12 16:35:30 +00:00
|
|
|
func TestLoad(t *testing.T) {
|
|
|
|
// Basically just testing that injection of the extra
|
|
|
|
// source works.
|
|
|
|
devMode := true
|
2020-12-21 18:25:32 +00:00
|
|
|
builderOpts := LoadOpts{
|
2020-08-12 16:35:30 +00:00
|
|
|
// putting this in dev mode so that the config validates
|
|
|
|
// without having to specify a data directory
|
|
|
|
DevMode: &devMode,
|
2020-12-21 18:25:32 +00:00
|
|
|
DefaultConfig: FileSource{
|
|
|
|
Name: "test",
|
|
|
|
Format: "hcl",
|
|
|
|
Data: `node_name = "hobbiton"`,
|
|
|
|
},
|
|
|
|
Overrides: []Source{
|
|
|
|
FileSource{
|
|
|
|
Name: "overrides",
|
|
|
|
Format: "json",
|
|
|
|
Data: `{"check_reap_interval": "1ms"}`,
|
|
|
|
},
|
|
|
|
},
|
2020-08-12 16:35:30 +00:00
|
|
|
}
|
|
|
|
|
2020-12-21 18:25:32 +00:00
|
|
|
result, err := Load(builderOpts)
|
2020-08-12 16:35:30 +00:00
|
|
|
require.NoError(t, err)
|
2020-12-21 18:25:32 +00:00
|
|
|
require.Empty(t, result.Warnings)
|
|
|
|
cfg := result.RuntimeConfig
|
2020-08-12 16:35:30 +00:00
|
|
|
require.NotNil(t, cfg)
|
|
|
|
require.Equal(t, "hobbiton", cfg.NodeName)
|
|
|
|
require.Equal(t, 1*time.Millisecond, cfg.CheckReapInterval)
|
|
|
|
}
|
|
|
|
|
2020-05-01 22:45:15 +00:00
|
|
|
func TestShouldParseFile(t *testing.T) {
|
|
|
|
var testcases = []struct {
|
|
|
|
filename string
|
|
|
|
configFormat string
|
|
|
|
expected bool
|
|
|
|
}{
|
|
|
|
{filename: "config.json", expected: true},
|
|
|
|
{filename: "config.hcl", expected: true},
|
|
|
|
{filename: "config", configFormat: "hcl", expected: true},
|
|
|
|
{filename: "config.js", configFormat: "json", expected: true},
|
|
|
|
{filename: "config.yaml", expected: false},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tc := range testcases {
|
|
|
|
name := fmt.Sprintf("filename=%s, format=%s", tc.filename, tc.configFormat)
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
require.Equal(t, tc.expected, shouldParseFile(tc.filename, tc.configFormat))
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestNewBuilder_PopulatesSourcesFromConfigFiles(t *testing.T) {
|
|
|
|
paths := setupConfigFiles(t)
|
|
|
|
|
2020-12-21 18:55:53 +00:00
|
|
|
b, err := newBuilder(LoadOpts{ConfigFiles: paths})
|
2020-05-01 22:45:15 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
expected := []Source{
|
2020-08-10 16:46:28 +00:00
|
|
|
FileSource{Name: paths[0], Format: "hcl", Data: "content a"},
|
|
|
|
FileSource{Name: paths[1], Format: "json", Data: "content b"},
|
|
|
|
FileSource{Name: filepath.Join(paths[3], "a.hcl"), Format: "hcl", Data: "content a"},
|
|
|
|
FileSource{Name: filepath.Join(paths[3], "b.json"), Format: "json", Data: "content b"},
|
2020-05-01 22:45:15 +00:00
|
|
|
}
|
|
|
|
require.Equal(t, expected, b.Sources)
|
2020-05-02 00:17:27 +00:00
|
|
|
require.Len(t, b.Warnings, 2)
|
2020-05-01 22:45:15 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
func TestNewBuilder_PopulatesSourcesFromConfigFiles_WithConfigFormat(t *testing.T) {
|
|
|
|
paths := setupConfigFiles(t)
|
|
|
|
|
2020-12-21 18:55:53 +00:00
|
|
|
b, err := newBuilder(LoadOpts{ConfigFiles: paths, ConfigFormat: "hcl"})
|
2020-05-01 22:45:15 +00:00
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
expected := []Source{
|
2020-08-10 16:46:28 +00:00
|
|
|
FileSource{Name: paths[0], Format: "hcl", Data: "content a"},
|
|
|
|
FileSource{Name: paths[1], Format: "hcl", Data: "content b"},
|
|
|
|
FileSource{Name: paths[2], Format: "hcl", Data: "content c"},
|
|
|
|
FileSource{Name: filepath.Join(paths[3], "a.hcl"), Format: "hcl", Data: "content a"},
|
|
|
|
FileSource{Name: filepath.Join(paths[3], "b.json"), Format: "hcl", Data: "content b"},
|
|
|
|
FileSource{Name: filepath.Join(paths[3], "c.yaml"), Format: "hcl", Data: "content c"},
|
2020-05-01 22:45:15 +00:00
|
|
|
}
|
|
|
|
require.Equal(t, expected, b.Sources)
|
|
|
|
}
|
|
|
|
|
|
|
|
// TODO: this would be much nicer with gotest.tools/fs
|
|
|
|
func setupConfigFiles(t *testing.T) []string {
|
|
|
|
t.Helper()
|
|
|
|
path, err := ioutil.TempDir("", t.Name())
|
|
|
|
require.NoError(t, err)
|
|
|
|
t.Cleanup(func() { os.RemoveAll(path) })
|
|
|
|
|
|
|
|
subpath := filepath.Join(path, "sub")
|
|
|
|
err = os.Mkdir(subpath, 0755)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
for _, dir := range []string{path, subpath} {
|
|
|
|
err = ioutil.WriteFile(filepath.Join(dir, "a.hcl"), []byte("content a"), 0644)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
err = ioutil.WriteFile(filepath.Join(dir, "b.json"), []byte("content b"), 0644)
|
|
|
|
require.NoError(t, err)
|
|
|
|
|
|
|
|
err = ioutil.WriteFile(filepath.Join(dir, "c.yaml"), []byte("content c"), 0644)
|
|
|
|
require.NoError(t, err)
|
|
|
|
}
|
|
|
|
return []string{
|
|
|
|
filepath.Join(path, "a.hcl"),
|
|
|
|
filepath.Join(path, "b.json"),
|
|
|
|
filepath.Join(path, "c.yaml"),
|
|
|
|
subpath,
|
|
|
|
}
|
|
|
|
}
|
2020-08-17 21:24:49 +00:00
|
|
|
|
2021-07-06 22:22:59 +00:00
|
|
|
func TestLoad_NodeName(t *testing.T) {
|
2020-08-17 21:24:49 +00:00
|
|
|
type testCase struct {
|
|
|
|
name string
|
|
|
|
nodeName string
|
|
|
|
expectedWarn string
|
|
|
|
}
|
|
|
|
|
|
|
|
fn := func(t *testing.T, tc testCase) {
|
2021-07-06 22:22:59 +00:00
|
|
|
opts := LoadOpts{
|
2020-12-21 18:25:32 +00:00
|
|
|
FlagValues: Config{
|
2020-08-17 21:24:49 +00:00
|
|
|
NodeName: pString(tc.nodeName),
|
|
|
|
DataDir: pString("dir"),
|
|
|
|
},
|
2021-07-06 22:22:59 +00:00
|
|
|
}
|
|
|
|
patchLoadOptsShims(&opts)
|
|
|
|
result, err := Load(opts)
|
2020-08-17 21:24:49 +00:00
|
|
|
require.NoError(t, err)
|
2021-07-06 22:22:59 +00:00
|
|
|
require.Len(t, result.Warnings, 1)
|
|
|
|
require.Contains(t, result.Warnings[0], tc.expectedWarn)
|
2020-08-17 21:24:49 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
var testCases = []testCase{
|
|
|
|
{
|
|
|
|
name: "invalid character - unicode",
|
|
|
|
nodeName: "🐼",
|
|
|
|
expectedWarn: `Node name "🐼" will not be discoverable via DNS due to invalid characters`,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: "invalid character - slash",
|
|
|
|
nodeName: "thing/other/ok",
|
|
|
|
expectedWarn: `Node name "thing/other/ok" will not be discoverable via DNS due to invalid characters`,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: "too long",
|
|
|
|
nodeName: strings.Repeat("a", 66),
|
|
|
|
expectedWarn: "due to it being too long.",
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tc := range testCases {
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
fn(t, tc)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2021-04-14 01:01:30 +00:00
|
|
|
func TestBuilder_unixPermissionsVal(t *testing.T) {
|
|
|
|
|
|
|
|
b, _ := newBuilder(LoadOpts{
|
|
|
|
FlagValues: Config{
|
|
|
|
NodeName: pString("foo"),
|
|
|
|
DataDir: pString("dir"),
|
|
|
|
},
|
|
|
|
})
|
|
|
|
|
|
|
|
goodmode := "666"
|
|
|
|
badmode := "9666"
|
|
|
|
|
|
|
|
patchLoadOptsShims(&b.opts)
|
|
|
|
require.NoError(t, b.err)
|
|
|
|
_ = b.unixPermissionsVal("local_bind_socket_mode", &goodmode)
|
|
|
|
require.NoError(t, b.err)
|
|
|
|
require.Len(t, b.Warnings, 0)
|
|
|
|
|
|
|
|
_ = b.unixPermissionsVal("local_bind_socket_mode", &badmode)
|
|
|
|
require.NotNil(t, b.err)
|
|
|
|
require.Contains(t, b.err.Error(), "local_bind_socket_mode: invalid mode")
|
|
|
|
require.Len(t, b.Warnings, 0)
|
|
|
|
}
|
|
|
|
|
2020-12-21 22:51:44 +00:00
|
|
|
func patchLoadOptsShims(opts *LoadOpts) {
|
|
|
|
if opts.hostname == nil {
|
|
|
|
opts.hostname = func() (string, error) {
|
|
|
|
return "thehostname", nil
|
|
|
|
}
|
2020-08-17 21:24:49 +00:00
|
|
|
}
|
2020-12-21 22:51:44 +00:00
|
|
|
if opts.getPrivateIPv4 == nil {
|
|
|
|
opts.getPrivateIPv4 = func() ([]*net.IPAddr, error) {
|
|
|
|
return []*net.IPAddr{ipAddr("10.0.0.1")}, nil
|
|
|
|
}
|
2020-08-17 21:24:49 +00:00
|
|
|
}
|
2020-12-21 22:51:44 +00:00
|
|
|
if opts.getPublicIPv6 == nil {
|
|
|
|
opts.getPublicIPv6 = func() ([]*net.IPAddr, error) {
|
|
|
|
return []*net.IPAddr{ipAddr("dead:beef::1")}, nil
|
|
|
|
}
|
2020-08-17 21:24:49 +00:00
|
|
|
}
|
|
|
|
}
|
2020-12-21 18:46:41 +00:00
|
|
|
|
|
|
|
func TestLoad_HTTPMaxConnsPerClientExceedsRLimit(t *testing.T) {
|
|
|
|
hcl := `
|
|
|
|
limits{
|
|
|
|
# We put a very high value to be sure to fail
|
|
|
|
# This value is more than max on Windows as well
|
|
|
|
http_max_conns_per_client = 16777217
|
|
|
|
}`
|
|
|
|
|
|
|
|
opts := LoadOpts{
|
|
|
|
DefaultConfig: FileSource{
|
|
|
|
Name: "test",
|
|
|
|
Format: "hcl",
|
|
|
|
Data: `
|
|
|
|
ae_interval = "1m"
|
|
|
|
data_dir="/tmp/00000000001979"
|
|
|
|
bind_addr = "127.0.0.1"
|
|
|
|
advertise_addr = "127.0.0.1"
|
|
|
|
datacenter = "dc1"
|
|
|
|
bootstrap = true
|
|
|
|
server = true
|
|
|
|
node_id = "00000000001979"
|
|
|
|
node_name = "Node-00000000001979"
|
|
|
|
`,
|
|
|
|
},
|
|
|
|
HCL: []string{hcl},
|
|
|
|
}
|
|
|
|
|
|
|
|
_, err := Load(opts)
|
|
|
|
require.Error(t, err)
|
|
|
|
assert.Contains(t, err.Error(), "but limits.http_max_conns_per_client: 16777217 needs at least 16777237")
|
|
|
|
}
|
2021-11-01 13:27:50 +00:00
|
|
|
|
|
|
|
func TestLoad_EmptyClientAddr(t *testing.T) {
|
|
|
|
|
|
|
|
type testCase struct {
|
|
|
|
name string
|
|
|
|
clientAddr *string
|
|
|
|
expectedWarningMessage *string
|
|
|
|
}
|
|
|
|
|
|
|
|
fn := func(t *testing.T, tc testCase) {
|
|
|
|
opts := LoadOpts{
|
|
|
|
FlagValues: Config{
|
|
|
|
ClientAddr: tc.clientAddr,
|
|
|
|
DataDir: pString("dir"),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
patchLoadOptsShims(&opts)
|
|
|
|
result, err := Load(opts)
|
|
|
|
require.NoError(t, err)
|
|
|
|
if tc.expectedWarningMessage != nil {
|
|
|
|
require.Len(t, result.Warnings, 1)
|
|
|
|
require.Contains(t, result.Warnings[0], *tc.expectedWarningMessage)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var testCases = []testCase{
|
|
|
|
{
|
|
|
|
name: "empty string",
|
|
|
|
clientAddr: pString(""),
|
|
|
|
expectedWarningMessage: pString("client_addr is empty, client services (DNS, HTTP, HTTPS, GRPC) will not be listening for connections"),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: "nil pointer",
|
|
|
|
clientAddr: nil, // defaults to 127.0.0.1
|
|
|
|
expectedWarningMessage: nil, // expecting no warnings
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tc := range testCases {
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
fn(t, tc)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
2021-12-23 21:30:36 +00:00
|
|
|
|
|
|
|
func TestBuilder_DurationVal_InvalidDuration(t *testing.T) {
|
|
|
|
b := builder{}
|
|
|
|
badDuration1 := "not-a-duration"
|
|
|
|
badDuration2 := "also-not"
|
|
|
|
b.durationVal("field1", &badDuration1)
|
|
|
|
b.durationVal("field1", &badDuration2)
|
|
|
|
|
|
|
|
require.Error(t, b.err)
|
|
|
|
require.Contains(t, b.err.Error(), "2 errors")
|
|
|
|
require.Contains(t, b.err.Error(), badDuration1)
|
|
|
|
require.Contains(t, b.err.Error(), badDuration2)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestBuilder_ServiceVal_MultiError(t *testing.T) {
|
|
|
|
b := builder{}
|
|
|
|
b.serviceVal(&ServiceDefinition{
|
|
|
|
Meta: map[string]string{"": "empty-key"},
|
|
|
|
Port: intPtr(12345),
|
|
|
|
SocketPath: strPtr("/var/run/socket.sock"),
|
|
|
|
Checks: []CheckDefinition{
|
|
|
|
{Interval: strPtr("bad-interval")},
|
|
|
|
},
|
|
|
|
Weights: &ServiceWeights{Passing: intPtr(-1)},
|
|
|
|
})
|
|
|
|
require.Error(t, b.err)
|
|
|
|
require.Contains(t, b.err.Error(), "4 errors")
|
|
|
|
require.Contains(t, b.err.Error(), "bad-interval")
|
|
|
|
require.Contains(t, b.err.Error(), "Key cannot be blank")
|
|
|
|
require.Contains(t, b.err.Error(), "Invalid weight")
|
|
|
|
require.Contains(t, b.err.Error(), "cannot have both socket path")
|
|
|
|
}
|
|
|
|
|
|
|
|
func intPtr(v int) *int {
|
|
|
|
return &v
|
|
|
|
}
|
2022-03-24 19:32:25 +00:00
|
|
|
|
|
|
|
func TestBuilder_tlsVersion(t *testing.T) {
|
|
|
|
b := builder{}
|
|
|
|
|
|
|
|
validTLSVersion := "TLSv1_3"
|
|
|
|
b.tlsVersion("tls.defaults.tls_min_version", &validTLSVersion)
|
|
|
|
|
|
|
|
deprecatedTLSVersion := "tls11"
|
|
|
|
b.tlsVersion("tls.defaults.tls_min_version", &deprecatedTLSVersion)
|
|
|
|
|
|
|
|
invalidTLSVersion := "tls9"
|
|
|
|
b.tlsVersion("tls.defaults.tls_min_version", &invalidTLSVersion)
|
|
|
|
|
|
|
|
require.Error(t, b.err)
|
|
|
|
require.Contains(t, b.err.Error(), "2 errors")
|
|
|
|
require.Contains(t, b.err.Error(), deprecatedTLSVersion)
|
|
|
|
require.Contains(t, b.err.Error(), invalidTLSVersion)
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestBuilder_tlsCipherSuites(t *testing.T) {
|
|
|
|
b := builder{}
|
|
|
|
|
|
|
|
validCipherSuites := strings.Join([]string{
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
|
|
|
|
"TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
|
|
|
|
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
|
|
|
|
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
|
|
|
|
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
|
|
|
|
"TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
|
|
|
|
}, ",")
|
|
|
|
b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &validCipherSuites, types.TLSv1_2)
|
|
|
|
require.NoError(t, b.err)
|
|
|
|
|
|
|
|
unsupportedCipherSuites := strings.Join([]string{
|
|
|
|
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
|
|
|
}, ",")
|
|
|
|
b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &unsupportedCipherSuites, types.TLSv1_2)
|
|
|
|
|
|
|
|
invalidCipherSuites := strings.Join([]string{
|
|
|
|
"cipherX",
|
|
|
|
}, ",")
|
|
|
|
b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &invalidCipherSuites, types.TLSv1_2)
|
|
|
|
|
|
|
|
b.tlsCipherSuites("tls.defaults.tls_cipher_suites", &validCipherSuites, types.TLSv1_3)
|
|
|
|
|
|
|
|
require.Error(t, b.err)
|
|
|
|
require.Contains(t, b.err.Error(), "3 errors")
|
|
|
|
require.Contains(t, b.err.Error(), unsupportedCipherSuites)
|
|
|
|
require.Contains(t, b.err.Error(), invalidCipherSuites)
|
|
|
|
require.Contains(t, b.err.Error(), "cipher suites are not configurable")
|
|
|
|
}
|
2022-03-31 17:49:37 +00:00
|
|
|
|
|
|
|
func TestBuilder_parsePrefixFilter(t *testing.T) {
|
|
|
|
t.Run("Check that 1.12 rpc metrics are parsed correctly.", func(t *testing.T) {
|
|
|
|
type testCase struct {
|
|
|
|
name string
|
|
|
|
metricsPrefix string
|
|
|
|
prefixFilter []string
|
|
|
|
expectedAllowedPrefix []string
|
|
|
|
expectedBlockedPrefix []string
|
|
|
|
}
|
|
|
|
|
|
|
|
var testCases = []testCase{
|
|
|
|
{
|
|
|
|
name: "no prefix filter",
|
|
|
|
metricsPrefix: "somePrefix",
|
|
|
|
prefixFilter: []string{},
|
|
|
|
expectedAllowedPrefix: nil,
|
|
|
|
expectedBlockedPrefix: []string{"somePrefix.rpc.server.call"},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: "operator enables 1.12 rpc metrics",
|
|
|
|
metricsPrefix: "somePrefix",
|
|
|
|
prefixFilter: []string{"+somePrefix.rpc.server.call"},
|
|
|
|
expectedAllowedPrefix: []string{"somePrefix.rpc.server.call"},
|
|
|
|
expectedBlockedPrefix: nil,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
name: "operator enables 1.12 rpc metrics",
|
|
|
|
metricsPrefix: "somePrefix",
|
|
|
|
prefixFilter: []string{"-somePrefix.rpc.server.call"},
|
|
|
|
expectedAllowedPrefix: nil,
|
|
|
|
expectedBlockedPrefix: []string{"somePrefix.rpc.server.call"},
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, tc := range testCases {
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
b := builder{}
|
|
|
|
telemetry := &Telemetry{
|
|
|
|
MetricsPrefix: &tc.metricsPrefix,
|
|
|
|
PrefixFilter: tc.prefixFilter,
|
|
|
|
}
|
|
|
|
|
|
|
|
allowedPrefix, blockedPrefix := b.parsePrefixFilter(telemetry)
|
|
|
|
|
|
|
|
require.Equal(t, tc.expectedAllowedPrefix, allowedPrefix)
|
|
|
|
require.Equal(t, tc.expectedBlockedPrefix, blockedPrefix)
|
|
|
|
})
|
|
|
|
}
|
|
|
|
})
|
|
|
|
}
|