|
|
|
@ -2,6 +2,7 @@ package service |
|
|
|
|
|
|
|
|
|
import ( |
|
|
|
|
"context" |
|
|
|
|
"fmt" |
|
|
|
|
"reflect" |
|
|
|
|
"testing" |
|
|
|
|
"time" |
|
|
|
@ -9,13 +10,12 @@ import ( |
|
|
|
|
"github.com/stretchr/testify/assert" |
|
|
|
|
"github.com/stretchr/testify/mock" |
|
|
|
|
"github.com/stretchr/testify/require" |
|
|
|
|
|
|
|
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
|
|
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" |
|
|
|
|
|
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity" |
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/utils" |
|
|
|
|
"github.com/grafana/grafana/pkg/apis/dashboard" |
|
|
|
|
dashboardv0alpha1 "github.com/grafana/grafana/pkg/apis/dashboard/v0alpha1" |
|
|
|
|
"github.com/grafana/grafana/pkg/components/simplejson" |
|
|
|
|
"github.com/grafana/grafana/pkg/infra/log" |
|
|
|
|
"github.com/grafana/grafana/pkg/services/apiserver/client" |
|
|
|
@ -540,31 +540,38 @@ func TestGetProvisionedDashboardData(t *testing.T) { |
|
|
|
|
|
|
|
|
|
t.Run("Should use Kubernetes client if feature flags are enabled and get from relevant org", func(t *testing.T) { |
|
|
|
|
ctx, k8sCliMock := setupK8sDashboardTests(service) |
|
|
|
|
provisioningTimestamp := int64(1234567) |
|
|
|
|
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"labels": map[string]any{ |
|
|
|
|
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{ |
|
|
|
|
Object: map[string]interface{}{ |
|
|
|
|
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(), |
|
|
|
|
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind, |
|
|
|
|
"metadata": map[string]interface{}{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"labels": map[string]interface{}{ |
|
|
|
|
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
|
|
|
|
}, |
|
|
|
|
"annotations": map[string]interface{}{ |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "test", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()), |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
"spec": map[string]interface{}{ |
|
|
|
|
"test": "test", |
|
|
|
|
"version": int64(1), |
|
|
|
|
"title": "testing slugify", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{ |
|
|
|
|
"test": "test", |
|
|
|
|
"version": int64(1), |
|
|
|
|
"title": "testing slugify", |
|
|
|
|
}, |
|
|
|
|
}}, nil).Once() |
|
|
|
|
}, nil).Once() |
|
|
|
|
repo := "test" |
|
|
|
|
k8sCliMock.On("Search", mock.Anything, int64(1), |
|
|
|
|
mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { |
|
|
|
|
// ensure the prefix is added to the query
|
|
|
|
|
return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo) |
|
|
|
|
// make sure the kind is added to the query
|
|
|
|
|
return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck
|
|
|
|
|
req.Options.Fields[1].Values[0] == repo |
|
|
|
|
})).Return(&resource.ResourceSearchResponse{ |
|
|
|
|
Results: &resource.ResourceTable{ |
|
|
|
|
Columns: []*resource.ResourceTableColumnDefinition{}, |
|
|
|
@ -573,8 +580,9 @@ func TestGetProvisionedDashboardData(t *testing.T) { |
|
|
|
|
TotalHits: 0, |
|
|
|
|
}, nil).Once() |
|
|
|
|
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { |
|
|
|
|
// ensure the prefix is added to the query
|
|
|
|
|
return req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix(repo) |
|
|
|
|
// make sure the kind is added to the query
|
|
|
|
|
return req.Options.Fields[0].Values[0] == string(utils.ManagerKindClassicFP) && // nolint:staticcheck
|
|
|
|
|
req.Options.Fields[1].Values[0] == repo |
|
|
|
|
})).Return(&resource.ResourceSearchResponse{ |
|
|
|
|
Results: &resource.ResourceTable{ |
|
|
|
|
Columns: []*resource.ResourceTableColumnDefinition{ |
|
|
|
@ -611,7 +619,7 @@ func TestGetProvisionedDashboardData(t *testing.T) { |
|
|
|
|
Name: "test", |
|
|
|
|
ExternalID: "path/to/file", |
|
|
|
|
CheckSum: "hash", |
|
|
|
|
Updated: 1735689600, |
|
|
|
|
Updated: provisioningTimestamp, |
|
|
|
|
}) |
|
|
|
|
k8sCliMock.AssertExpectations(t) |
|
|
|
|
}) |
|
|
|
@ -639,21 +647,25 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { |
|
|
|
|
|
|
|
|
|
t.Run("Should use Kubernetes client if feature flags are enabled and get from whatever org it is in", func(t *testing.T) { |
|
|
|
|
ctx, k8sCliMock := setupK8sDashboardTests(service) |
|
|
|
|
provisioningTimestamp := int64(1234567) |
|
|
|
|
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{ |
|
|
|
|
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(), |
|
|
|
|
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind, |
|
|
|
|
"metadata": map[string]interface{}{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"labels": map[string]any{ |
|
|
|
|
"labels": map[string]interface{}{ |
|
|
|
|
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
|
|
|
|
}, |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
"annotations": map[string]interface{}{ |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "test", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()), |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{ |
|
|
|
|
"spec": map[string]interface{}{ |
|
|
|
|
"test": "test", |
|
|
|
|
"version": int64(1), |
|
|
|
|
"title": "testing slugify", |
|
|
|
@ -701,7 +713,7 @@ func TestGetProvisionedDashboardDataByDashboardID(t *testing.T) { |
|
|
|
|
Name: "test", |
|
|
|
|
ExternalID: "path/to/file", |
|
|
|
|
CheckSum: "hash", |
|
|
|
|
Updated: 1735689600, |
|
|
|
|
Updated: provisioningTimestamp, |
|
|
|
|
}) |
|
|
|
|
k8sCliMock.AssertExpectations(t) |
|
|
|
|
}) |
|
|
|
@ -729,21 +741,25 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { |
|
|
|
|
|
|
|
|
|
t.Run("Should use Kubernetes client if feature flags are enabled", func(t *testing.T) { |
|
|
|
|
ctx, k8sCliMock := setupK8sDashboardTests(service) |
|
|
|
|
provisioningTimestamp := int64(1234567) |
|
|
|
|
k8sCliMock.On("GetNamespace", mock.Anything, mock.Anything).Return("default") |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]any{ |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{Object: map[string]interface{}{ |
|
|
|
|
"apiVersion": dashboardv0alpha1.DashboardResourceInfo.GroupVersion().String(), |
|
|
|
|
"kind": dashboardv0alpha1.DashboardResourceInfo.GroupVersionKind().Kind, |
|
|
|
|
"metadata": map[string]interface{}{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"labels": map[string]any{ |
|
|
|
|
"labels": map[string]interface{}{ |
|
|
|
|
utils.LabelKeyDeprecatedInternalID: "1", // nolint:staticcheck
|
|
|
|
|
}, |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
"annotations": map[string]interface{}{ |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "test", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()), |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{ |
|
|
|
|
"spec": map[string]interface{}{ |
|
|
|
|
"test": "test", |
|
|
|
|
"version": int64(1), |
|
|
|
|
"title": "testing slugify", |
|
|
|
@ -784,7 +800,7 @@ func TestGetProvisionedDashboardDataByDashboardUID(t *testing.T) { |
|
|
|
|
Name: "test", |
|
|
|
|
ExternalID: "path/to/file", |
|
|
|
|
CheckSum: "hash", |
|
|
|
|
Updated: 1735689600, |
|
|
|
|
Updated: provisioningTimestamp, |
|
|
|
|
}) |
|
|
|
|
k8sCliMock.AssertExpectations(t) |
|
|
|
|
}) |
|
|
|
@ -826,10 +842,11 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "orphaned", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{}, |
|
|
|
@ -839,8 +856,8 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid2", |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.PluginIDRepoName, |
|
|
|
|
utils.AnnoKeyRepoHash: "app", |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindPlugin), |
|
|
|
|
utils.AnnoKeyManagerIdentity: "app", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{}, |
|
|
|
@ -850,16 +867,17 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid3", |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("orphaned"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "orphaned", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{}, |
|
|
|
|
}}, nil).Once() |
|
|
|
|
k8sCliMock.On("Search", mock.Anything, int64(1), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { |
|
|
|
|
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" |
|
|
|
|
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin" |
|
|
|
|
})).Return(&resource.ResourceSearchResponse{ |
|
|
|
|
Results: &resource.ResourceTable{ |
|
|
|
|
Columns: []*resource.ResourceTableColumnDefinition{ |
|
|
|
@ -889,7 +907,7 @@ func TestDeleteOrphanedProvisionedDashboards(t *testing.T) { |
|
|
|
|
}, nil).Once() |
|
|
|
|
|
|
|
|
|
k8sCliMock.On("Search", mock.Anything, int64(2), mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { |
|
|
|
|
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.ProvisionedFileNameWithPrefix("test") && req.Options.Fields[0].Operator == "notin" |
|
|
|
|
return req.Options.Fields[0].Key == "manager.id" && req.Options.Fields[0].Values[0] == "test" && req.Options.Fields[0].Operator == "notin" |
|
|
|
|
})).Return(&resource.ResourceSearchResponse{ |
|
|
|
|
Results: &resource.ResourceTable{ |
|
|
|
|
Columns: []*resource.ResourceTableColumnDefinition{ |
|
|
|
@ -960,10 +978,11 @@ func TestUnprovisionDashboard(t *testing.T) { |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
utils.AnnoKeyManagerKind: utils.ManagerKindClassicFP, // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "test", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{}, |
|
|
|
@ -983,7 +1002,7 @@ func TestUnprovisionDashboard(t *testing.T) { |
|
|
|
|
}, |
|
|
|
|
}} |
|
|
|
|
// should update it to be without annotations
|
|
|
|
|
k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything, mock.Anything).Return(dashWithoutAnnotations, nil) |
|
|
|
|
k8sCliMock.On("Update", mock.Anything, dashWithoutAnnotations, mock.Anything).Return(dashWithoutAnnotations, nil) |
|
|
|
|
k8sCliMock.On("GetNamespace", mock.Anything).Return("default") |
|
|
|
|
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) |
|
|
|
|
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.Anything).Return(&resource.ResourceSearchResponse{ |
|
|
|
@ -1054,8 +1073,9 @@ func TestGetDashboardsByPluginID(t *testing.T) { |
|
|
|
|
k8sCliMock.On("Get", mock.Anything, "uid", mock.Anything, mock.Anything, mock.Anything).Return(uidUnstructured, nil) |
|
|
|
|
k8sCliMock.On("GetUserFromMeta", mock.Anything, mock.Anything).Return(&user.User{}, nil) |
|
|
|
|
k8sCliMock.On("Search", mock.Anything, mock.Anything, mock.MatchedBy(func(req *resource.ResourceSearchRequest) bool { |
|
|
|
|
return req.Options.Fields[0].Key == "repo.name" && req.Options.Fields[0].Values[0] == dashboard.PluginIDRepoName && |
|
|
|
|
req.Options.Fields[1].Key == "repo.path" && req.Options.Fields[1].Values[0] == "testing" |
|
|
|
|
return ( // gofmt comment helper
|
|
|
|
|
req.Options.Fields[0].Key == "manager.kind" && req.Options.Fields[0].Values[0] == string(utils.ManagerKindPlugin) && |
|
|
|
|
req.Options.Fields[1].Key == "manager.id" && req.Options.Fields[1].Values[0] == "testing") |
|
|
|
|
})).Return(&resource.ResourceSearchResponse{ |
|
|
|
|
Results: &resource.ResourceTable{ |
|
|
|
|
Columns: []*resource.ResourceTableColumnDefinition{ |
|
|
|
@ -1989,14 +2009,16 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) { |
|
|
|
|
query := &dashboards.FindPersistedDashboardsQuery{ |
|
|
|
|
OrgId: 1, |
|
|
|
|
} |
|
|
|
|
provisioningTimestamp := int64(1234567) |
|
|
|
|
dashboardUnstructuredProvisioned := unstructured.Unstructured{Object: map[string]any{ |
|
|
|
|
"metadata": map[string]any{ |
|
|
|
|
"name": "uid", |
|
|
|
|
"annotations": map[string]any{ |
|
|
|
|
utils.AnnoKeyRepoName: dashboard.ProvisionedFileNameWithPrefix("test"), |
|
|
|
|
utils.AnnoKeyRepoHash: "hash", |
|
|
|
|
utils.AnnoKeyRepoPath: "path/to/file", |
|
|
|
|
utils.AnnoKeyRepoTimestamp: "2025-01-01T00:00:00Z", |
|
|
|
|
utils.AnnoKeyManagerKind: string(utils.ManagerKindClassicFP), // nolint:staticcheck
|
|
|
|
|
utils.AnnoKeyManagerIdentity: "test", |
|
|
|
|
utils.AnnoKeySourceChecksum: "hash", |
|
|
|
|
utils.AnnoKeySourcePath: "path/to/file", |
|
|
|
|
utils.AnnoKeySourceTimestamp: fmt.Sprintf("%d", time.Unix(provisioningTimestamp, 0).UnixMilli()), |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
"spec": map[string]any{}, |
|
|
|
@ -2056,7 +2078,7 @@ func TestSearchProvisionedDashboardsThroughK8sRaw(t *testing.T) { |
|
|
|
|
Name: "test", |
|
|
|
|
ExternalID: "path/to/file", |
|
|
|
|
CheckSum: "hash", |
|
|
|
|
Updated: 1735689600, |
|
|
|
|
Updated: provisioningTimestamp, |
|
|
|
|
}, |
|
|
|
|
}, |
|
|
|
|
}, res) // only should return the one provisioned dashboard
|
|
|
|
|