Browse Source

Create a filter implementation for K8s `runtime.Object`s (#2631)

* Initial K8s Object filter matcher

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* More unit tests for K8s object matcher

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Switch filter fields to use common const strings

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Improve error message on unsupported field

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Switch to K8sObject-specific parser

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

* Register K8sObjectField in ops package

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>

---------

Signed-off-by: Michael Dresser <michaelmdresser@gmail.com>
Michael Dresser 2 years ago
parent
commit
b55dbfbf04

+ 20 - 16
core/pkg/filter/allocation/fields.go

@@ -1,5 +1,9 @@
 package allocation
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // AllocationField is an enum that represents Allocation-specific fields that can be
 // filtered on (namespace, label, etc.)
 type AllocationField string
@@ -8,17 +12,17 @@ type AllocationField string
 // Allocation value
 // does not enforce exhaustive pattern matching on "enum" types.
 const (
-	FieldClusterID      AllocationField = "cluster"
-	FieldNode           AllocationField = "node"
-	FieldNamespace      AllocationField = "namespace"
-	FieldControllerKind AllocationField = "controllerKind"
-	FieldControllerName AllocationField = "controllerName"
-	FieldPod            AllocationField = "pod"
-	FieldContainer      AllocationField = "container"
-	FieldProvider       AllocationField = "provider"
-	FieldServices       AllocationField = "services"
-	FieldLabel          AllocationField = "label"
-	FieldAnnotation     AllocationField = "annotation"
+	FieldClusterID      AllocationField = AllocationField(fieldstrings.FieldClusterID)
+	FieldNode           AllocationField = AllocationField(fieldstrings.FieldNode)
+	FieldNamespace      AllocationField = AllocationField(fieldstrings.FieldNamespace)
+	FieldControllerKind AllocationField = AllocationField(fieldstrings.FieldControllerKind)
+	FieldControllerName AllocationField = AllocationField(fieldstrings.FieldControllerName)
+	FieldPod            AllocationField = AllocationField(fieldstrings.FieldPod)
+	FieldContainer      AllocationField = AllocationField(fieldstrings.FieldContainer)
+	FieldProvider       AllocationField = AllocationField(fieldstrings.FieldProvider)
+	FieldServices       AllocationField = AllocationField(fieldstrings.FieldServices)
+	FieldLabel          AllocationField = AllocationField(fieldstrings.FieldLabel)
+	FieldAnnotation     AllocationField = AllocationField(fieldstrings.FieldAnnotation)
 )
 
 // AllocationAlias represents an alias field type for allocations.
@@ -30,9 +34,9 @@ const (
 type AllocationAlias string
 
 const (
-	AliasDepartment  AllocationAlias = "department"
-	AliasEnvironment AllocationAlias = "environment"
-	AliasOwner       AllocationAlias = "owner"
-	AliasProduct     AllocationAlias = "product"
-	AliasTeam        AllocationAlias = "team"
+	AliasDepartment  AllocationAlias = AllocationAlias(fieldstrings.AliasDepartment)
+	AliasEnvironment AllocationAlias = AllocationAlias(fieldstrings.AliasEnvironment)
+	AliasOwner       AllocationAlias = AllocationAlias(fieldstrings.AliasOwner)
+	AliasProduct     AllocationAlias = AllocationAlias(fieldstrings.AliasProduct)
+	AliasTeam        AllocationAlias = AllocationAlias(fieldstrings.AliasTeam)
 )

+ 19 - 15
core/pkg/filter/asset/fields.go

@@ -1,5 +1,9 @@
 package asset
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // AssetField is an enum that represents Asset-specific fields that can be
 // filtered on (namespace, label, etc.)
 type AssetField string
@@ -7,16 +11,16 @@ type AssetField string
 // If you add a AssetField, make sure to update field maps to return the correct
 // Asset value does not enforce exhaustive pattern matching on "enum" types.
 const (
-	FieldName       AssetField = "name"
-	FieldType       AssetField = "assetType"
-	FieldCategory   AssetField = "category"
-	FieldClusterID  AssetField = "cluster"
-	FieldProject    AssetField = "project"
-	FieldProvider   AssetField = "provider"
-	FieldProviderID AssetField = "providerID"
-	FieldAccount    AssetField = "account"
-	FieldService    AssetField = "service"
-	FieldLabel      AssetField = "label"
+	FieldName       AssetField = AssetField(fieldstrings.FieldName)
+	FieldType       AssetField = AssetField(fieldstrings.FieldType)
+	FieldCategory   AssetField = AssetField(fieldstrings.FieldCategory)
+	FieldClusterID  AssetField = AssetField(fieldstrings.FieldClusterID)
+	FieldProject    AssetField = AssetField(fieldstrings.FieldProject)
+	FieldProvider   AssetField = AssetField(fieldstrings.FieldProvider)
+	FieldProviderID AssetField = AssetField(fieldstrings.FieldProviderID)
+	FieldAccount    AssetField = AssetField(fieldstrings.FieldAccount)
+	FieldService    AssetField = AssetField(fieldstrings.FieldService)
+	FieldLabel      AssetField = AssetField(fieldstrings.FieldLabel)
 )
 
 // AssetAlias represents an alias field type for assets.
@@ -27,9 +31,9 @@ const (
 type AssetAlias string
 
 const (
-	DepartmentProp  AssetAlias = "department"
-	EnvironmentProp AssetAlias = "environment"
-	OwnerProp       AssetAlias = "owner"
-	ProductProp     AssetAlias = "product"
-	TeamProp        AssetAlias = "team"
+	DepartmentProp  AssetAlias = AssetAlias(fieldstrings.AliasDepartment)
+	EnvironmentProp AssetAlias = AssetAlias(fieldstrings.AliasEnvironment)
+	OwnerProp       AssetAlias = AssetAlias(fieldstrings.AliasOwner)
+	ProductProp     AssetAlias = AssetAlias(fieldstrings.AliasProduct)
+	TeamProp        AssetAlias = AssetAlias(fieldstrings.AliasTeam)
 )

+ 11 - 7
core/pkg/filter/cloudcost/fields.go

@@ -1,14 +1,18 @@
 package cloudcost
 
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
 // CloudCostField is an enum that represents CloudCost specific fields that can be filtered
 type CloudCostField string
 
 const (
-	FieldInvoiceEntityID CloudCostField = "invoiceEntityID"
-	FieldAccountID       CloudCostField = "accountID"
-	FieldProvider        CloudCostField = "provider"
-	FieldProviderID      CloudCostField = "providerID"
-	FieldCategory        CloudCostField = "category"
-	FieldService         CloudCostField = "service"
-	FieldLabel           CloudCostField = "label"
+	FieldInvoiceEntityID CloudCostField = CloudCostField(fieldstrings.FieldInvoiceEntityID)
+	FieldAccountID       CloudCostField = CloudCostField(fieldstrings.FieldAccountID)
+	FieldProvider        CloudCostField = CloudCostField(fieldstrings.FieldProvider)
+	FieldProviderID      CloudCostField = CloudCostField(fieldstrings.FieldProviderID)
+	FieldCategory        CloudCostField = CloudCostField(fieldstrings.FieldCategory)
+	FieldService         CloudCostField = CloudCostField(fieldstrings.FieldService)
+	FieldLabel           CloudCostField = CloudCostField(fieldstrings.FieldLabel)
 )

+ 35 - 0
core/pkg/filter/fieldstrings/fieldstrings.go

@@ -0,0 +1,35 @@
+package fieldstrings
+
+// These strings are the central source of filter fields across all types of
+// filters. Many filter types share fields; defining common consts means that
+// there should be no drift between types.
+const (
+	FieldClusterID      string = "cluster"
+	FieldNode           string = "node"
+	FieldNamespace      string = "namespace"
+	FieldControllerKind string = "controllerKind"
+	FieldControllerName string = "controllerName"
+	FieldPod            string = "pod"
+	FieldContainer      string = "container"
+	FieldProvider       string = "provider"
+	FieldServices       string = "services"
+	FieldLabel          string = "label"
+	FieldAnnotation     string = "annotation"
+
+	FieldName       string = "name"
+	FieldType       string = "assetType"
+	FieldCategory   string = "category"
+	FieldProject    string = "project"
+	FieldProviderID string = "providerID"
+	FieldAccount    string = "account"
+	FieldService    string = "service"
+
+	FieldInvoiceEntityID string = "invoiceEntityID"
+	FieldAccountID       string = "accountID"
+
+	AliasDepartment  string = "department"
+	AliasEnvironment string = "environment"
+	AliasOwner       string = "owner"
+	AliasProduct     string = "product"
+	AliasTeam        string = "team"
+)

+ 18 - 0
core/pkg/filter/k8sobject/fields.go

@@ -0,0 +1,18 @@
+package k8sobject
+
+import (
+	"github.com/opencost/opencost/core/pkg/filter/fieldstrings"
+)
+
+// K8sObjectField is an enum that represents K8sObject-specific fields that can
+// be filtered on.
+type K8sObjectField string
+
+const (
+	FieldNamespace      K8sObjectField = K8sObjectField(fieldstrings.FieldNamespace)
+	FieldControllerKind K8sObjectField = K8sObjectField(fieldstrings.FieldControllerKind)
+	FieldControllerName K8sObjectField = K8sObjectField(fieldstrings.FieldControllerName)
+	FieldPod            K8sObjectField = K8sObjectField(fieldstrings.FieldPod)
+	FieldLabel          K8sObjectField = K8sObjectField(fieldstrings.FieldLabel)
+	FieldAnnotation     K8sObjectField = K8sObjectField(fieldstrings.FieldAnnotation)
+)

+ 43 - 0
core/pkg/filter/k8sobject/parser.go

@@ -0,0 +1,43 @@
+package k8sobject
+
+import (
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+)
+
+// a slice of all the allocation field instances the lexer should recognize as
+// valid left-hand comparators
+var k8sObjectFilterFields []*ast.Field = []*ast.Field{
+	ast.NewField(FieldNamespace),
+	ast.NewField(FieldControllerName, ast.FieldAttributeNilable),
+	ast.NewField(FieldControllerKind, ast.FieldAttributeNilable),
+	ast.NewField(FieldPod),
+	ast.NewMapField(FieldLabel),
+	ast.NewMapField(FieldAnnotation),
+}
+
+// fieldMap is a lazily loaded mapping from AllocationField to ast.Field
+var fieldMap map[K8sObjectField]*ast.Field
+
+func init() {
+	fieldMap = make(map[K8sObjectField]*ast.Field, len(k8sObjectFilterFields))
+	for _, f := range k8sObjectFilterFields {
+		ff := *f
+		fieldMap[K8sObjectField(ff.Name)] = &ff
+	}
+}
+
+// DefaultFieldByName returns only default allocation filter fields by name.
+func DefaultFieldByName(field K8sObjectField) *ast.Field {
+	if af, ok := fieldMap[field]; ok {
+		afcopy := *af
+		return &afcopy
+	}
+
+	return nil
+}
+
+// NewK8sObjectFilterParser creates a new `ast.FilterParser` implementation for
+// K8s runtime.Objects.
+func NewK8sObjectFilterParser() ast.FilterParser {
+	return ast.NewFilterParser(k8sObjectFilterFields)
+}

+ 2 - 0
core/pkg/filter/ops/ops.go

@@ -13,6 +13,7 @@ import (
 	"github.com/opencost/opencost/core/pkg/filter/asset"
 	"github.com/opencost/opencost/core/pkg/filter/ast"
 	"github.com/opencost/opencost/core/pkg/filter/cloudcost"
+	"github.com/opencost/opencost/core/pkg/filter/k8sobject"
 	"github.com/opencost/opencost/core/pkg/util/typeutil"
 )
 
@@ -29,6 +30,7 @@ var defaultFieldByType = map[string]any{
 	typeutil.TypeOf[allocation.AllocationField](): allocation.DefaultFieldByName,
 	typeutil.TypeOf[asset.AssetField]():           asset.DefaultFieldByName,
 	typeutil.TypeOf[cloudcost.CloudCostField]():   cloudcost.DefaultFieldByName,
+	typeutil.TypeOf[k8sobject.K8sObjectField]():   k8sobject.DefaultFieldByName,
 	// typeutil.TypeOf[containerstats.ContainerStatsField](): containerstats.DefaultFieldByName,
 }
 

+ 139 - 0
core/pkg/opencost/k8sobjectmatcher.go

@@ -0,0 +1,139 @@
+package opencost
+
+import (
+	"fmt"
+
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+	kfilter "github.com/opencost/opencost/core/pkg/filter/k8sobject"
+	"github.com/opencost/opencost/core/pkg/filter/matcher"
+	"github.com/opencost/opencost/core/pkg/filter/transform"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+// K8sObjectMatcher is a matcher implementation for Kubernetes runtime.Object
+// instances, compiled using the matcher.MatchCompiler.
+type K8sObjectMatcher matcher.Matcher[runtime.Object]
+
+// NewK8sObjectMatchCompiler creates a new instance of a
+// matcher.MatchCompiler[runtime.Object] which can be used to compile
+// filter.Filter ASTs into matcher.Matcher[runtime.Object] implementations.
+//
+// If the label config is nil, the compiler will fail to compile alias filters
+// if any are present in the AST.
+func NewK8sObjectMatchCompiler() *matcher.MatchCompiler[runtime.Object] {
+	passes := []transform.CompilerPass{}
+
+	return matcher.NewMatchCompiler(
+		k8sObjectFieldMap,
+		k8sObjectSliceFieldMap,
+		k8sObjectMapFieldMap,
+		passes...,
+	)
+}
+
+func objectMetaFromObject(o runtime.Object) (metav1.ObjectMeta, error) {
+	switch v := o.(type) {
+	case *appsv1.Deployment:
+		return v.ObjectMeta, nil
+	case *appsv1.StatefulSet:
+		return v.ObjectMeta, nil
+	case *appsv1.DaemonSet:
+		return v.ObjectMeta, nil
+	case *corev1.Pod:
+		return v.ObjectMeta, nil
+	case *batchv1.CronJob:
+		return v.ObjectMeta, nil
+	}
+
+	return metav1.ObjectMeta{}, fmt.Errorf("currently-unsupported runtime.Object type for filtering: %T", o)
+}
+
+// Maps fields from an allocation to a string value based on an identifier
+func k8sObjectFieldMap(o runtime.Object, identifier ast.Identifier) (string, error) {
+	if identifier.Field == nil {
+		return "", fmt.Errorf("cannot map field from identifier with nil field")
+	}
+
+	m, err := objectMetaFromObject(o)
+	if err != nil {
+		return "", fmt.Errorf("retrieving object meta: %w", err)
+	}
+	var controllerKind string
+	var controllerName string
+	var pod string
+
+	switch v := o.(type) {
+	case *appsv1.Deployment:
+		controllerKind = "deployment"
+		controllerName = v.Name
+	case *appsv1.StatefulSet:
+		controllerKind = "statefulset"
+		controllerName = v.Name
+	case *appsv1.DaemonSet:
+		controllerKind = "daemonset"
+		controllerName = v.Name
+	case *corev1.Pod:
+		pod = v.Name
+		if len(v.OwnerReferences) == 0 {
+			controllerKind = "pod"
+			controllerName = v.Name
+		}
+	case *batchv1.CronJob:
+		controllerKind = "cronjob"
+		controllerName = v.Name
+	default:
+		return "", fmt.Errorf("currently-unsupported runtime.Object type for filtering: %T", o)
+	}
+
+	// For now, we will just do our best to implement Allocation fields because
+	// most k8s-based queries are on Allocation data. The other we will
+	// eventually want to support is Asset, but I'm not sure that I have time
+	// for that right now.
+	field := kfilter.K8sObjectField(identifier.Field.Name)
+	switch field {
+	case kfilter.FieldNamespace:
+		return m.Namespace, nil
+	case kfilter.FieldControllerName:
+		return controllerName, nil
+	case kfilter.FieldControllerKind:
+		return controllerKind, nil
+	case kfilter.FieldPod:
+		return pod, nil
+	case kfilter.FieldLabel:
+		if m.Labels != nil {
+			return m.Labels[identifier.Key], nil
+		}
+		return "", nil
+	case kfilter.FieldAnnotation:
+		if m.Annotations != nil {
+			return m.Annotations[identifier.Key], nil
+		}
+		return "", nil
+	}
+
+	return "", fmt.Errorf("Failed to find string identifier on K8sObject: %s (consider adding support if this is an expected field)", identifier.Field.Name)
+}
+
+// Maps slice fields from an allocation to a []string value based on an identifier
+func k8sObjectSliceFieldMap(o runtime.Object, identifier ast.Identifier) ([]string, error) {
+	return nil, fmt.Errorf("K8sObject filters current have no supported []string identifiers")
+}
+
+// Maps map fields from an allocation to a map[string]string value based on an identifier
+func k8sObjectMapFieldMap(o runtime.Object, identifier ast.Identifier) (map[string]string, error) {
+	m, err := objectMetaFromObject(o)
+	if err != nil {
+		return nil, fmt.Errorf("retrieving object meta: %w", err)
+	}
+	switch kfilter.K8sObjectField(identifier.Field.Name) {
+	case kfilter.FieldLabel:
+		return m.Labels, nil
+	case kfilter.FieldAnnotation:
+		return m.Annotations, nil
+	}
+	return nil, fmt.Errorf("Failed to find map[string]string identifier on K8sObject: %s", identifier.Field.Name)
+}

+ 222 - 0
core/pkg/opencost/k8sobjectmatcher_test.go

@@ -0,0 +1,222 @@
+package opencost
+
+import (
+	"testing"
+
+	"github.com/opencost/opencost/core/pkg/filter/ast"
+	k8sobject "github.com/opencost/opencost/core/pkg/filter/k8sobject"
+	appsv1 "k8s.io/api/apps/v1"
+	batchv1 "k8s.io/api/batch/v1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+)
+
+func TestK8sObjectMatcher(t *testing.T) {
+	cases := []struct {
+		filter string
+		o      runtime.Object
+
+		expected bool
+	}{
+		{
+			filter: `namespace:"kubecost"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "kubecost",
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `namespace:"kubecost"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Namespace: "kube-system",
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{Name: "bar"},
+			},
+			expected: false,
+		},
+		{
+			filter: `pod:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: false,
+		},
+		{
+			filter:   `controllerKind:"deployment"`,
+			o:        &appsv1.Deployment{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"statefulset"`,
+			o:        &appsv1.StatefulSet{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.StatefulSet{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"daemonset"`,
+			o:        &appsv1.DaemonSet{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &appsv1.DaemonSet{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"cronjob"`,
+			o:        &batchv1.CronJob{},
+			expected: true,
+		},
+		{
+			filter: `controllerName:"foo"`,
+			o: &batchv1.CronJob{
+				ObjectMeta: metav1.ObjectMeta{Name: "foo"},
+			},
+			expected: true,
+		},
+		{
+			filter:   `controllerKind:"pod"`,
+			o:        &corev1.Pod{},
+			expected: true,
+		},
+		{
+			filter: `controllerKind:"pod"`,
+			o: &corev1.Pod{
+				ObjectMeta: metav1.ObjectMeta{
+					OwnerReferences: []metav1.OwnerReference{
+						{}, // Having an owner reference makes this Pod "controlled"
+					},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{"app": "foo"},
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{"app": "bar"},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: map[string]string{},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `label[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Labels: nil,
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"app": "foo"},
+				},
+			},
+			expected: true,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{"app": "bar"},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: map[string]string{},
+				},
+			},
+			expected: false,
+		},
+		{
+			filter: `annotation[app]:"foo"`,
+			o: &appsv1.Deployment{
+				ObjectMeta: metav1.ObjectMeta{
+					Annotations: nil,
+				},
+			},
+			expected: false,
+		},
+	}
+
+	for _, c := range cases {
+		t.Run(c.filter, func(t *testing.T) {
+			parser := k8sobject.NewK8sObjectFilterParser()
+			parsed, err := parser.Parse(c.filter)
+			if err != nil {
+				t.Fatalf("parsing '%s': %s", c.filter, err)
+			}
+			t.Logf("Parsed: %s", ast.ToPreOrderString(parsed))
+
+			compiler := NewK8sObjectMatchCompiler()
+			matcher, err := compiler.Compile(parsed)
+			if err != nil {
+				t.Fatalf("compiling: %s", err)
+			}
+			t.Logf("Compiled: %s", matcher.String())
+
+			result := matcher.Matches(c.o)
+
+			if result != c.expected {
+				t.Errorf("Expected %t, got %t", c.expected, result)
+			}
+		})
+	}
+}