Skip to content

Commit

Permalink
Revert "Cloudwatch: ListMetrics API page limit (#31788)"
Browse files Browse the repository at this point in the history
This reverts commit d512c5a.
  • Loading branch information
sunker authored Mar 10, 2021
1 parent d512c5a commit 1c64d3e
Show file tree
Hide file tree
Showing 9 changed files with 62 additions and 135 deletions.
3 changes: 0 additions & 3 deletions conf/defaults.ini
Original file line number Diff line number Diff line change
Expand Up @@ -515,9 +515,6 @@ allowed_auth_providers = default,keys,credentials
# If true, assume role will be enabled for all AWS authentication providers that are specified in aws_auth_providers
assume_role_enabled = true

# Specify max no of pages to be returned by the ListMetricPages API
list_metrics_page_limit = 500

#################################### SMTP / Emailing #####################
[smtp]
enabled = false
Expand Down
4 changes: 0 additions & 4 deletions docs/sources/administration/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,10 +788,6 @@ Set to `false` to disable AWS authentication from using an assumed role with tem

If this option is disabled, the **Assume Role** and the **External Id** field are removed from the AWS data source configuration page. If the plugin is configured using provisioning, it is possible to use an assumed role as long as `assume_role_enabled` is set to `true`.

### list_metrics_page_limit

Use the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_ListMetrics.html) option to load metrics for custom namespaces in the CloudWatch data source. By default, the page limit is 500.

<hr />

## [smtp]
Expand Down
8 changes: 2 additions & 6 deletions docs/sources/datasources/cloudwatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ Filters syntax:
Example `ec2_instance_attribute()` query

```javascript
ec2_instance_attribute(us - east - 1, InstanceId, { 'tag:Environment': ['production'] });
ec2_instance_attribute(us-east-1, InstanceId, { "tag:Environment": ["production"] });
```

### Selecting attributes
Expand Down Expand Up @@ -341,7 +341,7 @@ Tags can be selected by prepending the tag name with `Tags.`
Example `ec2_instance_attribute()` query

```javascript
ec2_instance_attribute(us - east - 1, Tags.Name, { 'tag:Team': ['sysops'] });
ec2_instance_attribute(us-east-1, Tags.Name, { "tag:Team": ["sysops"] });
```

## Using json format template variables
Expand Down Expand Up @@ -385,10 +385,6 @@ Specify which authentication providers are allowed for the CloudWatch data sourc

Allows you to disable `assume role (ARN)` in the CloudWatch data source. By default, assume role (ARN) is enabled for OSS Grafana.

### list_metrics_page_limit

When a custom namespace is specified in the query editor, the [List Metrics API](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_ListMetrics.html) is used to populate the _Metrics_ field and the _Dimension_ fields. The API is paginated and returns up to 500 results per page. The CloudWatch data source also limits the number of pages to 500. However, you can change this limit using the `list_metrics_page_limit` variable in the [grafana configuration file](https://grafana.com/docs/grafana/latest/administration/configuration/#aws).

## Configure the data source with provisioning

It's now possible to configure data sources using config files with Grafana's provisioning system. You can read more about how it works and all the settings you can set for data sources on the [provisioning docs page]({{< relref "../administration/provisioning/#datasources" >}})
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0Zeo
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.13.0/go.mod h1:pqFyBUK3zZqMIIU5+8NaZq6/Ma3ClgUg9Hv5jfuJnvo=
cloud.google.com/go/storage v1.14.0 h1:6RRlFMv1omScs6iq2hfE3IvgE+l6RfJPampq8UZc5TU=
cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo=
code.cloudfoundry.org/bytefmt v0.0.0-20200131002437-cf55d5288a48/go.mod h1:wN/zk7mhREp/oviagqUXY3EwuHhWyOvAdsn5Y4CzOrc=
Expand Down Expand Up @@ -184,7 +183,6 @@ github.com/aws/aws-sdk-go v1.33.12/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZve
github.com/aws/aws-sdk-go v1.34.9/go.mod h1:5zCpMtNQVjRREroY7sYe8lOMRSxkhG6MZveU8YkpAk0=
github.com/aws/aws-sdk-go v1.35.5/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k=
github.com/aws/aws-sdk-go v1.35.30/go.mod h1:tlPOdRjfxPBpNIwqDj61rmsnA85v9jc0Ps9+muhnW+k=
github.com/aws/aws-sdk-go v1.37.25/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go v1.37.26 h1:D9Qvyjlr6xFR0CspZ0imdASc5Y1WE/Sgyte4l+cUp44=
github.com/aws/aws-sdk-go v1.37.26/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro=
github.com/aws/aws-sdk-go-v2 v0.18.0/go.mod h1:JWVYvqSMppoMJC0x5wdwiImzgXTI9FuZwxzkQq9wy+g=
Expand Down Expand Up @@ -1718,7 +1716,6 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210113205817-d3ed898aa8a3/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99 h1:5vD4XjIc0X5+kHZjx4UecYdjA6mJo+XXNoaW0EjU5Os=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down Expand Up @@ -1828,7 +1825,6 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201214210602-f9fddec55a1e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073 h1:8qxJSnu+7dRq6upnbntrmriWByIakBuct5OM/MdQC1M=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
Expand Down Expand Up @@ -1981,7 +1977,6 @@ google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz513
google.golang.org/api v0.32.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.38.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.40.0 h1:uWrpz12dpVPn7cojP82mk02XDgTJLDPc2KbVTxrWb4A=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
Expand Down Expand Up @@ -2045,7 +2040,6 @@ google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210203152818-3206188e46ba/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705 h1:PYBmACG+YEv8uQPW0r1kJj8tR+gkF0UWq7iFdUezwEw=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
Expand Down
2 changes: 0 additions & 2 deletions pkg/setting/setting.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,6 @@ type Cfg struct {
// AWS Plugin Auth
AWSAllowedAuthProviders []string
AWSAssumeRoleEnabled bool
AWSListMetricsPageLimit int

// Auth proxy settings
AuthProxyEnabled bool
Expand Down Expand Up @@ -945,7 +944,6 @@ func (cfg *Cfg) readAWSConfig() {
cfg.AWSAllowedAuthProviders = append(cfg.AWSAllowedAuthProviders, authProvider)
}
}
cfg.AWSListMetricsPageLimit = awsPluginSec.Key("list_metrics_page_limit").MustInt(500)
}

func (cfg *Cfg) readSessionConfig() {
Expand Down
1 change: 0 additions & 1 deletion pkg/tsdb/cloudwatch/log_actions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ func TestQuery_GetLogGroupFields(t *testing.T) {
}

const refID = "A"

executor := newExecutor(nil, newTestConfig())
resp, err := executor.DataQuery(context.Background(), fakeDataSource(), plugins.DataQuery{
Queries: []plugins.DataSubQuery{
Expand Down
88 changes: 56 additions & 32 deletions pkg/tsdb/cloudwatch/metric_find_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,21 +462,14 @@ func (e *cloudWatchExecutor) handleGetDimensionValues(ctx context.Context, param
}
}

params := &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
Dimensions: dimensions,
}
if metricName != "" {
params.MetricName = aws.String(metricName)
}
metrics, err := e.listMetrics(region, params)
metrics, err := e.cloudwatchListMetrics(region, namespace, metricName, dimensions)
if err != nil {
return nil, err
}

result := make([]suggestData, 0)
dupCheck := make(map[string]bool)
for _, metric := range metrics {
for _, metric := range metrics.Metrics {
for _, dim := range metric.Dimensions {
if *dim.Name == dimensionKey {
if _, exists := dupCheck[*dim.Value]; exists {
Expand Down Expand Up @@ -638,29 +631,36 @@ func (e *cloudWatchExecutor) handleGetResourceArns(ctx context.Context, paramete
return result, nil
}

func (e *cloudWatchExecutor) listMetrics(region string, params *cloudwatch.ListMetricsInput) ([]*cloudwatch.Metric, error) {
client, err := e.getCWClient(region)
func (e *cloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string,
dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) {
svc, err := e.getCWClient(region)
if err != nil {
return nil, err
}

plog.Debug("Listing metrics pages")
cloudWatchMetrics := []*cloudwatch.Metric{}
params := &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
Dimensions: dimensions,
}

pageNum := 0
err = client.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
pageNum++
metrics.MAwsCloudWatchListMetrics.Inc()
metrics, err := awsutil.ValuesAtPath(page, "Metrics")
if err == nil {
if metricName != "" {
params.MetricName = aws.String(metricName)
}

var resp cloudwatch.ListMetricsOutput
if err := svc.ListMetricsPages(params,
func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
metrics.MAwsCloudWatchListMetrics.Inc()
metrics, _ := awsutil.ValuesAtPath(page, "Metrics")
for _, metric := range metrics {
cloudWatchMetrics = append(cloudWatchMetrics, metric.(*cloudwatch.Metric))
resp.Metrics = append(resp.Metrics, metric.(*cloudwatch.Metric))
}
}
return !lastPage && pageNum < e.cfg.AWSListMetricsPageLimit
})
return !lastPage
}); err != nil {
return nil, fmt.Errorf("failed to call cloudwatch:ListMetrics: %w", err)
}

return cloudWatchMetrics, err
return &resp, nil
}

func (e *cloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2.Filter, instanceIds []*string) (*ec2.DescribeInstancesOutput, error) {
Expand Down Expand Up @@ -709,6 +709,34 @@ func (e *cloudWatchExecutor) resourceGroupsGetResources(region string, filters [
return &resp, nil
}

func (e *cloudWatchExecutor) getAllMetrics(region, namespace string) (cloudwatch.ListMetricsOutput, error) {
client, err := e.getCWClient(region)
if err != nil {
return cloudwatch.ListMetricsOutput{}, err
}

params := &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
}

plog.Debug("Listing metrics pages")
var resp cloudwatch.ListMetricsOutput
err = client.ListMetricsPages(params, func(page *cloudwatch.ListMetricsOutput, lastPage bool) bool {
metrics.MAwsCloudWatchListMetrics.Inc()
metrics, err := awsutil.ValuesAtPath(page, "Metrics")
if err != nil {
return !lastPage
}

for _, metric := range metrics {
resp.Metrics = append(resp.Metrics, metric.(*cloudwatch.Metric))
}
return !lastPage
})

return resp, err
}

var metricsCacheLock sync.Mutex

func (e *cloudWatchExecutor) getMetricsForCustomMetrics(region, namespace string) ([]string, error) {
Expand All @@ -732,18 +760,15 @@ func (e *cloudWatchExecutor) getMetricsForCustomMetrics(region, namespace string
if customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][namespace].Expire.After(time.Now()) {
return customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache, nil
}
metrics, err := e.listMetrics(region, &cloudwatch.ListMetricsInput{
Namespace: aws.String(namespace),
})

result, err := e.getAllMetrics(region, namespace)
if err != nil {
return []string{}, err
}

customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache = make([]string, 0)
customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][namespace].Expire = time.Now().Add(5 * time.Minute)

for _, metric := range metrics {
for _, metric := range result.Metrics {
if isDuplicate(customMetricsMetricsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache, *metric.MetricName) {
continue
}
Expand Down Expand Up @@ -776,15 +801,14 @@ func (e *cloudWatchExecutor) getDimensionsForCustomMetrics(region, namespace str
if customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][namespace].Expire.After(time.Now()) {
return customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache, nil
}

metrics, err := e.listMetrics(region, &cloudwatch.ListMetricsInput{Namespace: aws.String(namespace)})
result, err := e.getAllMetrics(region, namespace)
if err != nil {
return []string{}, err
}
customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache = make([]string, 0)
customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][namespace].Expire = time.Now().Add(5 * time.Minute)

for _, metric := range metrics {
for _, metric := range result.Metrics {
for _, dimension := range metric.Dimensions {
if isDuplicate(customMetricsDimensionsMap[dsInfo.Profile][dsInfo.Region][namespace].Cache, *dimension.Name) {
continue
Expand Down
48 changes: 0 additions & 48 deletions pkg/tsdb/cloudwatch/metric_find_query_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import (
"github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/setting"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -500,50 +499,3 @@ func TestQuery_ResourceARNs(t *testing.T) {
}, resp)
})
}

func TestQuery_ListMetricsPagination(t *testing.T) {
origNewCWClient := NewCWClient
t.Cleanup(func() {
NewCWClient = origNewCWClient
})

var client FakeCWClient

NewCWClient = func(sess *session.Session) cloudwatchiface.CloudWatchAPI {
return client
}

metrics := []*cloudwatch.Metric{
{MetricName: aws.String("Test_MetricName1")},
{MetricName: aws.String("Test_MetricName2")},
{MetricName: aws.String("Test_MetricName3")},
{MetricName: aws.String("Test_MetricName4")},
{MetricName: aws.String("Test_MetricName5")},
{MetricName: aws.String("Test_MetricName6")},
{MetricName: aws.String("Test_MetricName7")},
{MetricName: aws.String("Test_MetricName8")},
{MetricName: aws.String("Test_MetricName9")},
{MetricName: aws.String("Test_MetricName10")},
}

t.Run("List Metrics and page limit is reached", func(t *testing.T) {
client = FakeCWClient{Metrics: metrics, MetricsPerPage: 2}
executor := newExecutor(nil, &setting.Cfg{AWSListMetricsPageLimit: 3, AWSAllowedAuthProviders: []string{"default"}, AWSAssumeRoleEnabled: true})
executor.DataSource = fakeDataSource()
response, err := executor.listMetrics("default", &cloudwatch.ListMetricsInput{})
require.NoError(t, err)

expectedMetrics := client.MetricsPerPage * executor.cfg.AWSListMetricsPageLimit
assert.Equal(t, expectedMetrics, len(response))
})

t.Run("List Metrics and page limit is not reached", func(t *testing.T) {
client = FakeCWClient{Metrics: metrics, MetricsPerPage: 2}
executor := newExecutor(nil, &setting.Cfg{AWSListMetricsPageLimit: 1000, AWSAllowedAuthProviders: []string{"default"}, AWSAssumeRoleEnabled: true})
executor.DataSource = fakeDataSource()
response, err := executor.listMetrics("default", &cloudwatch.ListMetricsInput{})
require.NoError(t, err)

assert.Equal(t, len(metrics), len(response))
})
}
37 changes: 4 additions & 33 deletions pkg/tsdb/cloudwatch/test_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,24 +79,12 @@ type FakeCWClient struct {
cloudwatchiface.CloudWatchAPI

Metrics []*cloudwatch.Metric

MetricsPerPage int
}

func (c FakeCWClient) ListMetricsPages(input *cloudwatch.ListMetricsInput, fn func(*cloudwatch.ListMetricsOutput, bool) bool) error {
if c.MetricsPerPage == 0 {
c.MetricsPerPage = 1000
}
chunks := chunkSlice(c.Metrics, c.MetricsPerPage)

for i, metrics := range chunks {
response := fn(&cloudwatch.ListMetricsOutput{
Metrics: metrics,
}, i+1 == len(chunks))
if !response {
break
}
}
fn(&cloudwatch.ListMetricsOutput{
Metrics: c.Metrics,
}, true)
return nil
}

Expand Down Expand Up @@ -159,23 +147,6 @@ func (c fakeRGTAClient) GetResourcesPages(in *resourcegroupstaggingapi.GetResour
return nil
}

func chunkSlice(slice []*cloudwatch.Metric, chunkSize int) [][]*cloudwatch.Metric {
var chunks [][]*cloudwatch.Metric
for {
if len(slice) == 0 {
break
}
if len(slice) < chunkSize {
chunkSize = len(slice)
}

chunks = append(chunks, slice[0:chunkSize])
slice = slice[chunkSize:]
}

return chunks
}

func newTestConfig() *setting.Cfg {
return &setting.Cfg{AWSAllowedAuthProviders: []string{"default"}, AWSAssumeRoleEnabled: true, AWSListMetricsPageLimit: 1000}
return &setting.Cfg{AWSAllowedAuthProviders: []string{"default"}, AWSAssumeRoleEnabled: true}
}

0 comments on commit 1c64d3e

Please sign in to comment.