mirror of
https://github.com/projectcapsule/capsule.git
synced 2026-08-19 04:26:45 +00:00
* fix: revert pv labeling to dircet api reads Signed-off-by: Oliver Baehler <oliver@sudo-i.net> * fix: revert pv labeling to dircet api reads Signed-off-by: Oliver Baehler <oliver@sudo-i.net> --------- Signed-off-by: Oliver Baehler <oliver@sudo-i.net>
566 lines
16 KiB
Go
566 lines
16 KiB
Go
// Copyright 2020-2023 Project Capsule Authors.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package e2e
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/utils/ptr"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
|
|
capsulev1beta2 "github.com/projectcapsule/capsule/api/v1beta2"
|
|
"github.com/projectcapsule/capsule/pkg/api/meta"
|
|
"github.com/projectcapsule/capsule/pkg/api/rbac"
|
|
)
|
|
|
|
var _ = Describe("preventing PersistentVolume cross-tenant mount", Ordered, Label("tenant", "storage", "persistentvolumeclaim"), func() {
|
|
tnt1 := &capsulev1beta2.Tenant{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "e2e-pv-cross-one",
|
|
Labels: map[string]string{
|
|
"env": "e2e",
|
|
},
|
|
},
|
|
Spec: capsulev1beta2.TenantSpec{
|
|
Owners: rbac.OwnerListSpec{
|
|
{
|
|
CoreOwnerSpec: rbac.CoreOwnerSpec{
|
|
UserSpec: rbac.UserSpec{
|
|
Name: "e2e-pv-cross-one",
|
|
Kind: "User",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
tnt2 := &capsulev1beta2.Tenant{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "e2e-pv-cross-two",
|
|
Labels: map[string]string{
|
|
"env": "e2e",
|
|
},
|
|
},
|
|
Spec: capsulev1beta2.TenantSpec{
|
|
Owners: rbac.OwnerListSpec{
|
|
{
|
|
CoreOwnerSpec: rbac.CoreOwnerSpec{
|
|
UserSpec: rbac.UserSpec{
|
|
Name: "e2e-pv-cross-two",
|
|
Kind: "User",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
JustBeforeEach(func() {
|
|
for _, tnt := range []*capsulev1beta2.Tenant{tnt1, tnt2} {
|
|
EventuallyCreation(func() error {
|
|
tnt.ResourceVersion = ""
|
|
|
|
return k8sClient.Create(context.TODO(), tnt)
|
|
}).Should(Succeed())
|
|
|
|
TenantReady(tnt, metav1.ConditionTrue, defaultTimeoutInterval)
|
|
}
|
|
})
|
|
|
|
JustAfterEach(func() {
|
|
for _, tnt := range []*capsulev1beta2.Tenant{tnt1, tnt2} {
|
|
EventuallyDeletion(tnt)
|
|
}
|
|
})
|
|
|
|
It("should add labels to PersistentVolume and prevent cross-Tenant mount", Label("skip-on-openshift"), func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
|
|
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "arrakis",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("standard"),
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}).Should(Succeed())
|
|
|
|
pod := corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "arrakis-pod",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PodSpec{
|
|
SecurityContext: nobodyPodSecurityContext(),
|
|
Containers: []corev1.Container{
|
|
{
|
|
Name: "container",
|
|
Image: "registry.k8s.io/pause:3.10",
|
|
ImagePullPolicy: corev1.PullAlways,
|
|
SecurityContext: restrictedContainerSecurityContext(),
|
|
VolumeMounts: []corev1.VolumeMount{
|
|
{
|
|
Name: "data",
|
|
MountPath: "/tmp",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Volumes: []corev1.Volume{
|
|
{
|
|
Name: "data",
|
|
VolumeSource: corev1.VolumeSource{
|
|
PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{
|
|
ClaimName: pvc.Name,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pod)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func() int {
|
|
nsName := types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace}
|
|
|
|
if err := k8sClient.Get(context.Background(), nsName, &pvc); err != nil {
|
|
return 0
|
|
}
|
|
|
|
return len(pvc.Spec.VolumeName)
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(BeNumerically(">", 0))
|
|
|
|
pv := corev1.PersistentVolume{}
|
|
defer func() {
|
|
_ = k8sClient.Delete(context.Background(), &pv)
|
|
}()
|
|
|
|
Eventually(func() string {
|
|
if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: pvc.Spec.VolumeName}, &pv); err != nil {
|
|
return "not-found"
|
|
}
|
|
|
|
if pv.GetLabels() == nil {
|
|
return "no-labels"
|
|
}
|
|
|
|
return pv.GetLabels()["capsule.clastix.io/tenant"]
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Equal(tnt1.Name))
|
|
|
|
Eventually(func() error {
|
|
nsName := types.NamespacedName{Name: pv.Name}
|
|
|
|
if err := k8sClient.Get(context.Background(), nsName, &pv); err != nil {
|
|
return err
|
|
}
|
|
|
|
pv.Spec.PersistentVolumeReclaimPolicy = corev1.PersistentVolumeReclaimRecycle
|
|
|
|
return k8sClient.Update(context.Background(), &pv)
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
|
|
Expect(k8sClient.Delete(context.Background(), &pod, &client.DeleteOptions{GracePeriodSeconds: ptr.To(int64(0))})).ToNot(HaveOccurred())
|
|
|
|
ns2 := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt2.GetName(),
|
|
})
|
|
NamespaceCreation(ns2, tnt2.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt2, ns2).Should(Succeed())
|
|
|
|
Consistently(func() error {
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "caladan",
|
|
Namespace: ns2.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("standard"),
|
|
VolumeName: pv.Name,
|
|
},
|
|
}
|
|
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(HaveOccurred())
|
|
})
|
|
|
|
It("should repair empty and stale Tenant labels on claimed PersistentVolumes", Label("skip-on-openshift"), func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
|
|
volumes := make([]*corev1.PersistentVolume, 0, 2)
|
|
defer func() {
|
|
for _, pv := range volumes {
|
|
_ = k8sClient.Delete(context.Background(), pv)
|
|
}
|
|
}()
|
|
|
|
for index, value := range []string{"", tnt2.Name} {
|
|
pv := &corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: fmt.Sprintf("repair-tenant-label-%d", index),
|
|
Labels: map[string]string{
|
|
meta.TenantLabel: value,
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
Capacity: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain,
|
|
StorageClassName: "manual",
|
|
ClaimRef: &corev1.ObjectReference{
|
|
APIVersion: "v1",
|
|
Kind: "PersistentVolumeClaim",
|
|
Namespace: ns.Name,
|
|
Name: fmt.Sprintf("repair-tenant-label-%d", index),
|
|
},
|
|
PersistentVolumeSource: corev1.PersistentVolumeSource{
|
|
HostPath: &corev1.HostPathVolumeSource{
|
|
Path: fmt.Sprintf("/tmp/capsule-e2e-repair-tenant-label-%d", index),
|
|
},
|
|
},
|
|
},
|
|
}
|
|
volumes = append(volumes, pv)
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), pv)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
current := &corev1.PersistentVolume{}
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pv.Name},
|
|
current,
|
|
)).To(Succeed())
|
|
g.Expect(current.GetLabels()).To(HaveKeyWithValue(meta.TenantLabel, tnt1.Name))
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
}
|
|
})
|
|
|
|
It("should not add a selector when updating an already-bound dynamic PVC without selector", func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
|
|
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "dynamic-pvc",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("standard"),
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace},
|
|
&pvc,
|
|
)).To(Succeed())
|
|
|
|
g.Expect(pvc.Spec.Selector).To(BeNil())
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
|
|
pvc.Labels = map[string]string{
|
|
"updated": "true",
|
|
}
|
|
|
|
Eventually(func() error {
|
|
return k8sClient.Update(context.Background(), &pvc)
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
updated := corev1.PersistentVolumeClaim{}
|
|
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace},
|
|
&updated,
|
|
)).To(Succeed())
|
|
|
|
g.Expect(updated.Labels).To(HaveKeyWithValue("updated", "true"))
|
|
g.Expect(updated.Spec.Selector).To(BeNil())
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
})
|
|
|
|
It("should add the Tenant selector to a PVC with an existing selector", func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
|
|
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "selector-pvc",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("standard"),
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
"storage-tier": "gold",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
created := corev1.PersistentVolumeClaim{}
|
|
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace},
|
|
&created,
|
|
)).To(Succeed())
|
|
|
|
g.Expect(created.Spec.Selector).ToNot(BeNil())
|
|
g.Expect(created.Spec.Selector.MatchLabels).To(HaveKeyWithValue("storage-tier", "gold"))
|
|
g.Expect(created.Spec.Selector.MatchLabels).ToNot(HaveKey(meta.TenantLabel))
|
|
g.Expect(created.Spec.Selector.MatchExpressions).To(ContainElement(metav1.LabelSelectorRequirement{
|
|
Key: meta.TenantLabel,
|
|
Operator: metav1.LabelSelectorOpIn,
|
|
Values: []string{tnt1.Name},
|
|
}))
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
})
|
|
|
|
It("should overwrite conflicting Tenant selectors on PVC creation", func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
|
|
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "conflicting-selector-pvc",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("standard"),
|
|
Selector: &metav1.LabelSelector{
|
|
MatchLabels: map[string]string{
|
|
meta.TenantLabel: tnt2.Name,
|
|
"storage-tier": "gold",
|
|
},
|
|
MatchExpressions: []metav1.LabelSelectorRequirement{
|
|
{
|
|
Key: meta.TenantLabel,
|
|
Operator: metav1.LabelSelectorOpNotIn,
|
|
Values: []string{tnt1.Name},
|
|
},
|
|
{
|
|
Key: "environment",
|
|
Operator: metav1.LabelSelectorOpIn,
|
|
Values: []string{"test"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
created := corev1.PersistentVolumeClaim{}
|
|
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace},
|
|
&created,
|
|
)).To(Succeed())
|
|
|
|
g.Expect(created.Spec.Selector).ToNot(BeNil())
|
|
|
|
// The mutating webhook must preserve unrelated selector requirements.
|
|
g.Expect(created.Spec.Selector.MatchLabels).To(HaveKeyWithValue("storage-tier", "gold"))
|
|
g.Expect(created.Spec.Selector.MatchExpressions).To(ContainElement(metav1.LabelSelectorRequirement{
|
|
Key: "environment",
|
|
Operator: metav1.LabelSelectorOpIn,
|
|
Values: []string{"test"},
|
|
}))
|
|
|
|
// The tenant selector must be canonicalized.
|
|
g.Expect(created.Spec.Selector.MatchLabels).ToNot(HaveKey(meta.TenantLabel))
|
|
|
|
tenantExpressions := 0
|
|
for _, expression := range created.Spec.Selector.MatchExpressions {
|
|
if expression.Key != meta.TenantLabel {
|
|
continue
|
|
}
|
|
|
|
tenantExpressions++
|
|
|
|
g.Expect(expression.Operator).To(Equal(metav1.LabelSelectorOpIn))
|
|
g.Expect(expression.Values).To(Equal([]string{tnt1.Name}))
|
|
}
|
|
|
|
g.Expect(tenantExpressions).To(Equal(1))
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
})
|
|
|
|
It("should add the Tenant selector to a pre-bound PVC", func() {
|
|
ns := NewNamespace("", map[string]string{
|
|
meta.TenantLabel: tnt1.GetName(),
|
|
})
|
|
NamespaceCreation(ns, tnt1.Spec.Owners[0].UserSpec, defaultTimeoutInterval).Should(Succeed())
|
|
NamespaceIsPartOfTenant(tnt1, ns).Should(Succeed())
|
|
|
|
pv := corev1.PersistentVolume{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "prebound-pv",
|
|
Labels: map[string]string{
|
|
meta.TenantLabel: tnt1.Name,
|
|
},
|
|
},
|
|
Spec: corev1.PersistentVolumeSpec{
|
|
Capacity: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
PersistentVolumeReclaimPolicy: corev1.PersistentVolumeReclaimRetain,
|
|
StorageClassName: "manual",
|
|
PersistentVolumeSource: corev1.PersistentVolumeSource{
|
|
HostPath: &corev1.HostPathVolumeSource{
|
|
Path: "/tmp/capsule-e2e-prebound-pv",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pv)
|
|
}).Should(Succeed())
|
|
|
|
defer func() {
|
|
_ = k8sClient.Delete(context.Background(), &pv)
|
|
}()
|
|
|
|
pvc := corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "prebound-pvc",
|
|
Namespace: ns.Name,
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{
|
|
AccessModes: []corev1.PersistentVolumeAccessMode{
|
|
corev1.ReadWriteOnce,
|
|
},
|
|
Resources: corev1.VolumeResourceRequirements{
|
|
Requests: corev1.ResourceList{
|
|
corev1.ResourceStorage: resource.MustParse("1Gi"),
|
|
},
|
|
},
|
|
StorageClassName: ptr.To("manual"),
|
|
VolumeName: pv.Name,
|
|
},
|
|
}
|
|
|
|
EventuallyCreation(func() error {
|
|
return k8sClient.Create(context.Background(), &pvc)
|
|
}).Should(Succeed())
|
|
|
|
Eventually(func(g Gomega) {
|
|
created := corev1.PersistentVolumeClaim{}
|
|
|
|
g.Expect(k8sClient.Get(
|
|
context.Background(),
|
|
types.NamespacedName{Name: pvc.Name, Namespace: pvc.Namespace},
|
|
&created,
|
|
)).To(Succeed())
|
|
|
|
g.Expect(created.Spec.VolumeName).To(Equal(pv.Name))
|
|
g.Expect(created.Spec.Selector).ToNot(BeNil())
|
|
g.Expect(created.Spec.Selector.MatchExpressions).To(ContainElement(metav1.LabelSelectorRequirement{
|
|
Key: meta.TenantLabel,
|
|
Operator: metav1.LabelSelectorOpIn,
|
|
Values: []string{tnt1.Name},
|
|
}))
|
|
}, defaultTimeoutInterval, defaultPollInterval).Should(Succeed())
|
|
})
|
|
|
|
})
|