mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-07-30 15:05:27 +00:00
plumb PodSelector through the api
This commit is contained in:
parent
407f9b9e42
commit
29c50cdc1a
67
pkg/apis/extensions/helpers.go
Normal file
67
pkg/apis/extensions/helpers.go
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package extensions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"k8s.io/kubernetes/pkg/labels"
|
||||||
|
"k8s.io/kubernetes/pkg/util/sets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PodSelectorAsSelector converts the PodSelector api type into a struct that implements
|
||||||
|
// labels.Selector
|
||||||
|
func PodSelectorAsSelector(ps *PodSelector) (labels.Selector, error) {
|
||||||
|
if ps == nil {
|
||||||
|
return labels.Nothing(), nil
|
||||||
|
}
|
||||||
|
if len(ps.MatchLabels)+len(ps.MatchExpressions) == 0 {
|
||||||
|
return labels.Everything(), nil
|
||||||
|
}
|
||||||
|
selector := labels.LabelSelector{}
|
||||||
|
for k, v := range ps.MatchLabels {
|
||||||
|
req, err := labels.NewRequirement(k, labels.InOperator, sets.NewString(v))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector = append(selector, *req)
|
||||||
|
}
|
||||||
|
for _, expr := range ps.MatchExpressions {
|
||||||
|
var op labels.Operator
|
||||||
|
switch expr.Operator {
|
||||||
|
case PodSelectorOpIn:
|
||||||
|
op = labels.InOperator
|
||||||
|
case PodSelectorOpNotIn:
|
||||||
|
op = labels.NotInOperator
|
||||||
|
case PodSelectorOpExists:
|
||||||
|
op = labels.ExistsOperator
|
||||||
|
case PodSelectorOpDoesNotExist:
|
||||||
|
op = labels.DoesNotExistOperator
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("%q is not a valid pod selector operator", expr.Operator)
|
||||||
|
}
|
||||||
|
req, err := labels.NewRequirement(expr.Key, op, sets.NewString(expr.Values...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector = append(selector, *req)
|
||||||
|
}
|
||||||
|
sort.Sort(labels.ByKey(selector))
|
||||||
|
return selector, nil
|
||||||
|
}
|
83
pkg/apis/extensions/helpers_test.go
Normal file
83
pkg/apis/extensions/helpers_test.go
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package extensions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"k8s.io/kubernetes/pkg/labels"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPodSelectorAsSelector(t *testing.T) {
|
||||||
|
matchLabels := map[string]string{"foo": "bar"}
|
||||||
|
matchExpressions := []PodSelectorRequirement{{
|
||||||
|
Key: "baz",
|
||||||
|
Operator: PodSelectorOpIn,
|
||||||
|
Values: []string{"qux", "norf"},
|
||||||
|
}}
|
||||||
|
mustParse := func(s string) labels.Selector {
|
||||||
|
out, e := labels.Parse(s)
|
||||||
|
if e != nil {
|
||||||
|
panic(e)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
tc := []struct {
|
||||||
|
in *PodSelector
|
||||||
|
out labels.Selector
|
||||||
|
expectErr bool
|
||||||
|
}{
|
||||||
|
{in: nil, out: labels.Nothing()},
|
||||||
|
{in: &PodSelector{}, out: labels.Everything()},
|
||||||
|
{
|
||||||
|
in: &PodSelector{MatchLabels: matchLabels},
|
||||||
|
out: mustParse("foo in (bar)"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: &PodSelector{MatchExpressions: matchExpressions},
|
||||||
|
out: mustParse("baz in (norf,qux)"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: &PodSelector{MatchLabels: matchLabels, MatchExpressions: matchExpressions},
|
||||||
|
out: mustParse("foo in (bar),baz in (norf,qux)"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
in: &PodSelector{
|
||||||
|
MatchExpressions: []PodSelectorRequirement{{
|
||||||
|
Key: "baz",
|
||||||
|
Operator: PodSelectorOpExists,
|
||||||
|
Values: []string{"qux", "norf"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
expectErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, tc := range tc {
|
||||||
|
out, err := PodSelectorAsSelector(tc.in)
|
||||||
|
if err == nil && tc.expectErr {
|
||||||
|
t.Errorf("[%v]expected error but got none.", i)
|
||||||
|
}
|
||||||
|
if err != nil && !tc.expectErr {
|
||||||
|
t.Errorf("[%v]did not expect error but got: %v", i, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(out, tc.out) {
|
||||||
|
t.Errorf("[%v]expected:\n\t%+v\nbut got:\n\t%+v", i, tc.out, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -405,7 +405,7 @@ type JobSpec struct {
|
|||||||
Completions *int `json:"completions,omitempty"`
|
Completions *int `json:"completions,omitempty"`
|
||||||
|
|
||||||
// Selector is a label query over pods that should match the pod count.
|
// Selector is a label query over pods that should match the pod count.
|
||||||
Selector map[string]string `json:"selector"`
|
Selector *PodSelector `json:"selector,omitempty"`
|
||||||
|
|
||||||
// Template is the object that describes the pod that will be created when
|
// Template is the object that describes the pod that will be created when
|
||||||
// executing a job.
|
// executing a job.
|
||||||
@ -661,7 +661,7 @@ type PodSelector struct {
|
|||||||
MatchExpressions []PodSelectorRequirement `json:"matchExpressions,omitempty"`
|
MatchExpressions []PodSelectorRequirement `json:"matchExpressions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pod selector requirement is a selector that contains values, a key and an operator that
|
// A pod selector requirement is a selector that contains values, a key, and an operator that
|
||||||
// relates the key and values.
|
// relates the key and values.
|
||||||
type PodSelectorRequirement struct {
|
type PodSelectorRequirement struct {
|
||||||
// key is the label key that the selector applies to.
|
// key is the label key that the selector applies to.
|
||||||
@ -669,10 +669,11 @@ type PodSelectorRequirement struct {
|
|||||||
// operator represents a key's relationship to a set of values.
|
// operator represents a key's relationship to a set of values.
|
||||||
// Valid operators ard In, NotIn, Exists and DoesNotExist.
|
// Valid operators ard In, NotIn, Exists and DoesNotExist.
|
||||||
Operator PodSelectorOperator `json:"operator"`
|
Operator PodSelectorOperator `json:"operator"`
|
||||||
// values is a set of string values. If the operator is In or NotIn,
|
// values is an array of string values. If the operator is In or NotIn,
|
||||||
// the values set must be non-empty. This array is replaced during a
|
// the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
// strategic merge patch.
|
// the values array must be empty. This array is replaced during a strategic
|
||||||
Values []string `json:"stringValues,omitempty"`
|
// merge patch.
|
||||||
|
Values []string `json:"values,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// A pod selector operator is the set of operators that can be used in a selector requirement.
|
// A pod selector operator is the set of operators that can be used in a selector requirement.
|
||||||
|
@ -92,8 +92,10 @@ func addDefaultingFuncs() {
|
|||||||
labels := obj.Spec.Template.Labels
|
labels := obj.Spec.Template.Labels
|
||||||
// TODO: support templates defined elsewhere when we support them in the API
|
// TODO: support templates defined elsewhere when we support them in the API
|
||||||
if labels != nil {
|
if labels != nil {
|
||||||
if len(obj.Spec.Selector) == 0 {
|
if obj.Spec.Selector == nil {
|
||||||
obj.Spec.Selector = labels
|
obj.Spec.Selector = &PodSelector{
|
||||||
|
MatchLabels: labels,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if len(obj.Labels) == 0 {
|
if len(obj.Labels) == 0 {
|
||||||
obj.Labels = labels
|
obj.Labels = labels
|
||||||
|
@ -192,7 +192,9 @@ func TestSetDefaultDeployment(t *testing.T) {
|
|||||||
func TestSetDefaultJob(t *testing.T) {
|
func TestSetDefaultJob(t *testing.T) {
|
||||||
expected := &Job{
|
expected := &Job{
|
||||||
Spec: JobSpec{
|
Spec: JobSpec{
|
||||||
Selector: map[string]string{"job": "selector"},
|
Selector: &PodSelector{
|
||||||
|
MatchLabels: map[string]string{"job": "selector"},
|
||||||
|
},
|
||||||
Completions: newInt(1),
|
Completions: newInt(1),
|
||||||
Parallelism: newInt(1),
|
Parallelism: newInt(1),
|
||||||
},
|
},
|
||||||
@ -201,7 +203,9 @@ func TestSetDefaultJob(t *testing.T) {
|
|||||||
// selector set explicitly, completions and parallelism - default
|
// selector set explicitly, completions and parallelism - default
|
||||||
{
|
{
|
||||||
Spec: JobSpec{
|
Spec: JobSpec{
|
||||||
Selector: map[string]string{"job": "selector"},
|
Selector: &PodSelector{
|
||||||
|
MatchLabels: map[string]string{"job": "selector"},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// selector from template labels, completions and parallelism - default
|
// selector from template labels, completions and parallelism - default
|
||||||
|
@ -412,7 +412,7 @@ type JobSpec struct {
|
|||||||
|
|
||||||
// Selector is a label query over pods that should match the pod count.
|
// Selector is a label query over pods that should match the pod count.
|
||||||
// More info: http://releases.k8s.io/HEAD/docs/user-guide/labels.md#label-selectors
|
// More info: http://releases.k8s.io/HEAD/docs/user-guide/labels.md#label-selectors
|
||||||
Selector map[string]string `json:"selector,omitempty"`
|
Selector *PodSelector `json:"selector,omitempty"`
|
||||||
|
|
||||||
// Template is the object that describes the pod that will be created when
|
// Template is the object that describes the pod that will be created when
|
||||||
// executing a job.
|
// executing a job.
|
||||||
@ -657,3 +657,40 @@ type ClusterAutoscalerList struct {
|
|||||||
|
|
||||||
Items []ClusterAutoscaler `json:"items"`
|
Items []ClusterAutoscaler `json:"items"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A pod selector is a label query over a set of pods. The result of matchLabels and
|
||||||
|
// matchExpressions are ANDed. An empty pod selector matches all objects. A null
|
||||||
|
// pod selector matches no objects.
|
||||||
|
type PodSelector struct {
|
||||||
|
// matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
|
||||||
|
// map is equivalent to an element of matchExpressions, whose key field is "key", the
|
||||||
|
// operator is "In", and the values array contains only "value". The requirements are ANDed.
|
||||||
|
MatchLabels map[string]string `json:"matchLabels,omitempty"`
|
||||||
|
// matchExpressions is a list of pod selector requirements. The requirements are ANDed.
|
||||||
|
MatchExpressions []PodSelectorRequirement `json:"matchExpressions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pod selector requirement is a selector that contains values, a key, and an operator that
|
||||||
|
// relates the key and values.
|
||||||
|
type PodSelectorRequirement struct {
|
||||||
|
// key is the label key that the selector applies to.
|
||||||
|
Key string `json:"key" patchStrategy:"merge" patchMergeKey:"key"`
|
||||||
|
// operator represents a key's relationship to a set of values.
|
||||||
|
// Valid operators ard In, NotIn, Exists and DoesNotExist.
|
||||||
|
Operator PodSelectorOperator `json:"operator"`
|
||||||
|
// values is an array of string values. If the operator is In or NotIn,
|
||||||
|
// the values array must be non-empty. If the operator is Exists or DoesNotExist,
|
||||||
|
// the values array must be empty. This array is replaced during a strategic
|
||||||
|
// merge patch.
|
||||||
|
Values []string `json:"values,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pod selector operator is the set of operators that can be used in a selector requirement.
|
||||||
|
type PodSelectorOperator string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PodSelectorOpIn PodSelectorOperator = "In"
|
||||||
|
PodSelectorOpNotIn PodSelectorOperator = "NotIn"
|
||||||
|
PodSelectorOpExists PodSelectorOperator = "Exists"
|
||||||
|
PodSelectorOpDoesNotExist PodSelectorOperator = "DoesNotExist"
|
||||||
|
)
|
||||||
|
@ -345,16 +345,19 @@ func ValidateJobSpec(spec *extensions.JobSpec) errs.ValidationErrorList {
|
|||||||
if spec.Completions != nil && *spec.Completions < 0 {
|
if spec.Completions != nil && *spec.Completions < 0 {
|
||||||
allErrs = append(allErrs, errs.NewFieldInvalid("completions", spec.Completions, isNegativeErrorMsg))
|
allErrs = append(allErrs, errs.NewFieldInvalid("completions", spec.Completions, isNegativeErrorMsg))
|
||||||
}
|
}
|
||||||
|
if spec.Selector == nil {
|
||||||
selector := labels.Set(spec.Selector).AsSelector()
|
|
||||||
if selector.Empty() {
|
|
||||||
allErrs = append(allErrs, errs.NewFieldRequired("selector"))
|
allErrs = append(allErrs, errs.NewFieldRequired("selector"))
|
||||||
|
} else {
|
||||||
|
allErrs = append(allErrs, ValidatePodSelector(spec.Selector).Prefix("selector")...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if selector, err := extensions.PodSelectorAsSelector(spec.Selector); err == nil {
|
||||||
labels := labels.Set(spec.Template.Labels)
|
labels := labels.Set(spec.Template.Labels)
|
||||||
if !selector.Matches(labels) {
|
if !selector.Matches(labels) {
|
||||||
allErrs = append(allErrs, errs.NewFieldInvalid("template.labels", spec.Template.Labels, "selector does not match template"))
|
allErrs = append(allErrs, errs.NewFieldInvalid("template.metadata.labels", spec.Template.Labels, "selector does not match template"))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
allErrs = append(allErrs, apivalidation.ValidatePodTemplateSpec(&spec.Template).Prefix("template")...)
|
allErrs = append(allErrs, apivalidation.ValidatePodTemplateSpec(&spec.Template).Prefix("template")...)
|
||||||
if spec.Template.Spec.RestartPolicy != api.RestartPolicyOnFailure &&
|
if spec.Template.Spec.RestartPolicy != api.RestartPolicyOnFailure &&
|
||||||
spec.Template.Spec.RestartPolicy != api.RestartPolicyNever {
|
spec.Template.Spec.RestartPolicy != api.RestartPolicyNever {
|
||||||
@ -568,3 +571,33 @@ func ValidateClusterAutoscaler(autoscaler *extensions.ClusterAutoscaler) errs.Va
|
|||||||
allErrs = append(allErrs, validateClusterAutoscalerSpec(autoscaler.Spec)...)
|
allErrs = append(allErrs, validateClusterAutoscalerSpec(autoscaler.Spec)...)
|
||||||
return allErrs
|
return allErrs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ValidatePodSelector(ps *extensions.PodSelector) errs.ValidationErrorList {
|
||||||
|
allErrs := errs.ValidationErrorList{}
|
||||||
|
if ps == nil {
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
allErrs = append(allErrs, apivalidation.ValidateLabels(ps.MatchLabels, "matchLabels")...)
|
||||||
|
for i, expr := range ps.MatchExpressions {
|
||||||
|
allErrs = append(allErrs, ValidatePodSelectorRequirement(expr).Prefix(fmt.Sprintf("matchExpressions.[%v]", i))...)
|
||||||
|
}
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidatePodSelectorRequirement(sr extensions.PodSelectorRequirement) errs.ValidationErrorList {
|
||||||
|
allErrs := errs.ValidationErrorList{}
|
||||||
|
switch sr.Operator {
|
||||||
|
case extensions.PodSelectorOpIn, extensions.PodSelectorOpNotIn:
|
||||||
|
if len(sr.Values) == 0 {
|
||||||
|
allErrs = append(allErrs, errs.NewFieldInvalid("values", sr.Values, "must be non-empty when operator is In or NotIn"))
|
||||||
|
}
|
||||||
|
case extensions.PodSelectorOpExists, extensions.PodSelectorOpDoesNotExist:
|
||||||
|
if len(sr.Values) > 0 {
|
||||||
|
allErrs = append(allErrs, errs.NewFieldInvalid("values", sr.Values, "must be empty when operator is Exists or DoesNotExist"))
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
allErrs = append(allErrs, errs.NewFieldInvalid("operator", sr.Operator, "not a valid pod selector operator"))
|
||||||
|
}
|
||||||
|
allErrs = append(allErrs, apivalidation.ValidateLabelName(sr.Key, "key")...)
|
||||||
|
return allErrs
|
||||||
|
}
|
||||||
|
@ -192,7 +192,6 @@ func TestValidateDaemonSetStatusUpdate(t *testing.T) {
|
|||||||
t.Errorf("expected failure: %s", testName)
|
t.Errorf("expected failure: %s", testName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateDaemonSetUpdate(t *testing.T) {
|
func TestValidateDaemonSetUpdate(t *testing.T) {
|
||||||
@ -725,10 +724,12 @@ func TestValidateDeployment(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateJob(t *testing.T) {
|
func TestValidateJob(t *testing.T) {
|
||||||
validSelector := map[string]string{"a": "b"}
|
validSelector := &extensions.PodSelector{
|
||||||
|
MatchLabels: map[string]string{"a": "b"},
|
||||||
|
}
|
||||||
validPodTemplateSpec := api.PodTemplateSpec{
|
validPodTemplateSpec := api.PodTemplateSpec{
|
||||||
ObjectMeta: api.ObjectMeta{
|
ObjectMeta: api.ObjectMeta{
|
||||||
Labels: validSelector,
|
Labels: validSelector.MatchLabels,
|
||||||
},
|
},
|
||||||
Spec: api.PodSpec{
|
Spec: api.PodSpec{
|
||||||
RestartPolicy: api.RestartPolicyOnFailure,
|
RestartPolicy: api.RestartPolicyOnFailure,
|
||||||
@ -783,11 +784,10 @@ func TestValidateJob(t *testing.T) {
|
|||||||
Namespace: api.NamespaceDefault,
|
Namespace: api.NamespaceDefault,
|
||||||
},
|
},
|
||||||
Spec: extensions.JobSpec{
|
Spec: extensions.JobSpec{
|
||||||
Selector: map[string]string{},
|
|
||||||
Template: validPodTemplateSpec,
|
Template: validPodTemplateSpec,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"spec.template.labels:selector does not match template": {
|
"spec.template.metadata.labels: invalid value 'map[y:z]', Details: selector does not match template": {
|
||||||
ObjectMeta: api.ObjectMeta{
|
ObjectMeta: api.ObjectMeta{
|
||||||
Name: "myjob",
|
Name: "myjob",
|
||||||
Namespace: api.NamespaceDefault,
|
Namespace: api.NamespaceDefault,
|
||||||
@ -815,7 +815,7 @@ func TestValidateJob(t *testing.T) {
|
|||||||
Selector: validSelector,
|
Selector: validSelector,
|
||||||
Template: api.PodTemplateSpec{
|
Template: api.PodTemplateSpec{
|
||||||
ObjectMeta: api.ObjectMeta{
|
ObjectMeta: api.ObjectMeta{
|
||||||
Labels: validSelector,
|
Labels: validSelector.MatchLabels,
|
||||||
},
|
},
|
||||||
Spec: api.PodSpec{
|
Spec: api.PodSpec{
|
||||||
RestartPolicy: api.RestartPolicyAlways,
|
RestartPolicy: api.RestartPolicyAlways,
|
||||||
|
@ -47,6 +47,18 @@ func Everything() Selector {
|
|||||||
return LabelSelector{}
|
return LabelSelector{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type nothingSelector struct{}
|
||||||
|
|
||||||
|
func (n nothingSelector) Matches(_ Labels) bool { return false }
|
||||||
|
func (n nothingSelector) Empty() bool { return false }
|
||||||
|
func (n nothingSelector) String() string { return "<null>" }
|
||||||
|
func (n nothingSelector) Add(_ string, _ Operator, _ []string) Selector { return n }
|
||||||
|
|
||||||
|
// Nothing returns a selector that matches no labels
|
||||||
|
func Nothing() Selector {
|
||||||
|
return nothingSelector{}
|
||||||
|
}
|
||||||
|
|
||||||
// Operator represents a key's relationship
|
// Operator represents a key's relationship
|
||||||
// to a set of values in a Requirement.
|
// to a set of values in a Requirement.
|
||||||
type Operator string
|
type Operator string
|
||||||
|
Loading…
Reference in New Issue
Block a user