Labels and annotation support for functions and environments (#2113)
1. Support for adding labels and annotations to functions & environment via fission CLI through create & update command. 2. Change ensures labels and annotations assigned to the environment would reflect on pods created via executor type pool manager and new deploy. Signed-off-by: Sanket Sudake <sanketsudake@gmail.com>
This commit is contained in:
@@ -47,6 +47,7 @@ import (
|
|||||||
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
|
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
|
||||||
"github.com/fission/fission/pkg/throttler"
|
"github.com/fission/fission/pkg/throttler"
|
||||||
"github.com/fission/fission/pkg/utils"
|
"github.com/fission/fission/pkg/utils"
|
||||||
|
"github.com/fission/fission/pkg/utils/maps"
|
||||||
)
|
)
|
||||||
|
|
||||||
var _ executortype.ExecutorType = &NewDeploy{}
|
var _ executortype.ExecutorType = &NewDeploy{}
|
||||||
@@ -426,7 +427,7 @@ func (deploy *NewDeploy) fnCreate(fn *fv1.Function) (*fscache.FuncSvc, error) {
|
|||||||
|
|
||||||
objName := deploy.getObjName(fn)
|
objName := deploy.getObjName(fn)
|
||||||
deployLabels := deploy.getDeployLabels(fn.ObjectMeta, env.ObjectMeta)
|
deployLabels := deploy.getDeployLabels(fn.ObjectMeta, env.ObjectMeta)
|
||||||
deployAnnotations := deploy.getDeployAnnotations(fn.ObjectMeta)
|
deployAnnotations := deploy.getDeployAnnotations(fn.ObjectMeta, env.ObjectMeta)
|
||||||
|
|
||||||
// to support backward compatibility, if the function was created in default ns, we fall back to creating the
|
// to support backward compatibility, if the function was created in default ns, we fall back to creating the
|
||||||
// deployment of the function in fission-function ns
|
// deployment of the function in fission-function ns
|
||||||
@@ -666,7 +667,7 @@ func (deploy *NewDeploy) updateFuncDeployment(fn *fv1.Function, env *fv1.Environ
|
|||||||
// Therefore, the deployment update will trigger a rolling update.
|
// Therefore, the deployment update will trigger a rolling update.
|
||||||
newDeployment, err := deploy.getDeploymentSpec(fn, env,
|
newDeployment, err := deploy.getDeploymentSpec(fn, env,
|
||||||
existingDepl.Spec.Replicas, // use current replicas instead of minscale in the ExecutionStrategy.
|
existingDepl.Spec.Replicas, // use current replicas instead of minscale in the ExecutionStrategy.
|
||||||
fnObjName, ns, deployLabels, deploy.getDeployAnnotations(fn.ObjectMeta))
|
fnObjName, ns, deployLabels, deploy.getDeployAnnotations(fn.ObjectMeta, env.ObjectMeta))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
deploy.updateStatus(fn, err, "failed to get new deployment spec while updating function")
|
deploy.updateStatus(fn, err, "failed to get new deployment spec while updating function")
|
||||||
return err
|
return err
|
||||||
@@ -724,7 +725,7 @@ func (deploy *NewDeploy) getObjName(fn *fv1.Function) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (deploy *NewDeploy) getDeployLabels(fnMeta metav1.ObjectMeta, envMeta metav1.ObjectMeta) map[string]string {
|
func (deploy *NewDeploy) getDeployLabels(fnMeta metav1.ObjectMeta, envMeta metav1.ObjectMeta) map[string]string {
|
||||||
return map[string]string{
|
deployLabels := map[string]string{
|
||||||
fv1.EXECUTOR_TYPE: string(fv1.ExecutorTypeNewdeploy),
|
fv1.EXECUTOR_TYPE: string(fv1.ExecutorTypeNewdeploy),
|
||||||
fv1.ENVIRONMENT_NAME: envMeta.Name,
|
fv1.ENVIRONMENT_NAME: envMeta.Name,
|
||||||
fv1.ENVIRONMENT_NAMESPACE: envMeta.Namespace,
|
fv1.ENVIRONMENT_NAMESPACE: envMeta.Namespace,
|
||||||
@@ -733,13 +734,17 @@ func (deploy *NewDeploy) getDeployLabels(fnMeta metav1.ObjectMeta, envMeta metav
|
|||||||
fv1.FUNCTION_NAMESPACE: fnMeta.Namespace,
|
fv1.FUNCTION_NAMESPACE: fnMeta.Namespace,
|
||||||
fv1.FUNCTION_UID: string(fnMeta.UID),
|
fv1.FUNCTION_UID: string(fnMeta.UID),
|
||||||
}
|
}
|
||||||
|
for k, v := range envMeta.Labels {
|
||||||
|
deployLabels[k] = v
|
||||||
|
}
|
||||||
|
return deployLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
func (deploy *NewDeploy) getDeployAnnotations(fnMeta metav1.ObjectMeta) map[string]string {
|
func (deploy *NewDeploy) getDeployAnnotations(fnMeta metav1.ObjectMeta, envMeta metav1.ObjectMeta) map[string]string {
|
||||||
return map[string]string{
|
deployAnnotations := maps.CopyStringMap(envMeta.Annotations)
|
||||||
fv1.EXECUTOR_INSTANCEID_LABEL: deploy.instanceID,
|
deployAnnotations[fv1.EXECUTOR_INSTANCEID_LABEL] = deploy.instanceID
|
||||||
fv1.FUNCTION_RESOURCE_VERSION: fnMeta.ResourceVersion,
|
deployAnnotations[fv1.FUNCTION_RESOURCE_VERSION] = fnMeta.ResourceVersion
|
||||||
}
|
return deployAnnotations
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateStatus is a function which updates status of update.
|
// updateStatus is a function which updates status of update.
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import (
|
|||||||
fetcherClient "github.com/fission/fission/pkg/fetcher/client"
|
fetcherClient "github.com/fission/fission/pkg/fetcher/client"
|
||||||
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
|
fetcherConfig "github.com/fission/fission/pkg/fetcher/config"
|
||||||
"github.com/fission/fission/pkg/utils"
|
"github.com/fission/fission/pkg/utils"
|
||||||
|
"github.com/fission/fission/pkg/utils/maps"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -155,19 +156,19 @@ func MakeGenericPool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (gp *GenericPool) getEnvironmentPoolLabels() map[string]string {
|
func (gp *GenericPool) getEnvironmentPoolLabels() map[string]string {
|
||||||
return map[string]string{
|
envLabels := maps.CopyStringMap(gp.env.ObjectMeta.Labels)
|
||||||
fv1.EXECUTOR_TYPE: string(fv1.ExecutorTypePoolmgr),
|
envLabels[fv1.EXECUTOR_TYPE] = string(fv1.ExecutorTypePoolmgr)
|
||||||
fv1.ENVIRONMENT_NAME: gp.env.ObjectMeta.Name,
|
envLabels[fv1.ENVIRONMENT_NAME] = gp.env.ObjectMeta.Name
|
||||||
fv1.ENVIRONMENT_NAMESPACE: gp.env.ObjectMeta.Namespace,
|
envLabels[fv1.ENVIRONMENT_NAMESPACE] = gp.env.ObjectMeta.Namespace
|
||||||
fv1.ENVIRONMENT_UID: string(gp.env.ObjectMeta.UID),
|
envLabels[fv1.ENVIRONMENT_UID] = string(gp.env.ObjectMeta.UID)
|
||||||
"managed": "true", // this allows us to easily find pods managed by the deployment
|
envLabels["managed"] = "true" // this allows us to easily find pods managed by the deployment
|
||||||
}
|
return envLabels
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gp *GenericPool) getDeployAnnotations() map[string]string {
|
func (gp *GenericPool) getDeployAnnotations() map[string]string {
|
||||||
return map[string]string{
|
deployAnnotations := maps.CopyStringMap(gp.env.Annotations)
|
||||||
fv1.EXECUTOR_INSTANCEID_LABEL: gp.instanceID,
|
deployAnnotations[fv1.EXECUTOR_INSTANCEID_LABEL] = gp.instanceID
|
||||||
}
|
return deployAnnotations
|
||||||
}
|
}
|
||||||
|
|
||||||
func (gp *GenericPool) checkMetricsApi() bool {
|
func (gp *GenericPool) checkMetricsApi() bool {
|
||||||
|
|||||||
@@ -31,10 +31,13 @@ func Commands() *cobra.Command {
|
|||||||
}
|
}
|
||||||
wrapper.SetFlags(createCmd, flag.FlagSet{
|
wrapper.SetFlags(createCmd, flag.FlagSet{
|
||||||
Required: []flag.Flag{flag.EnvName, flag.EnvImage},
|
Required: []flag.Flag{flag.EnvName, flag.EnvImage},
|
||||||
Optional: []flag.Flag{flag.EnvPoolsize, flag.EnvBuilderImage, flag.EnvBuildCmd,
|
Optional: []flag.Flag{
|
||||||
|
flag.EnvPoolsize, flag.EnvBuilderImage, flag.EnvBuildCmd,
|
||||||
flag.RunTimeMinCPU, flag.RunTimeMaxCPU, flag.RunTimeMinMemory, flag.RunTimeMaxMemory,
|
flag.RunTimeMinCPU, flag.RunTimeMaxCPU, flag.RunTimeMinMemory, flag.RunTimeMaxMemory,
|
||||||
flag.EnvTerminationGracePeriod, flag.EnvVersion, flag.EnvImagePullSecret,
|
flag.EnvTerminationGracePeriod, flag.EnvVersion, flag.EnvImagePullSecret, flag.EnvKeepArchive,
|
||||||
flag.EnvExternalNetwork, flag.EnvKeepArchive, flag.NamespaceEnvironment, flag.SpecSave, flag.SpecDry},
|
flag.NamespaceEnvironment, flag.EnvExternalNetwork,
|
||||||
|
flag.Labels, flag.Annotation,
|
||||||
|
flag.SpecSave, flag.SpecDry},
|
||||||
})
|
})
|
||||||
|
|
||||||
getCmd := &cobra.Command{
|
getCmd := &cobra.Command{
|
||||||
@@ -57,7 +60,9 @@ func Commands() *cobra.Command {
|
|||||||
Optional: []flag.Flag{flag.EnvImage, flag.EnvPoolsize,
|
Optional: []flag.Flag{flag.EnvImage, flag.EnvPoolsize,
|
||||||
flag.EnvBuilderImage, flag.EnvBuildCmd, flag.EnvImagePullSecret,
|
flag.EnvBuilderImage, flag.EnvBuildCmd, flag.EnvImagePullSecret,
|
||||||
flag.RunTimeMinCPU, flag.RunTimeMaxCPU, flag.RunTimeMinMemory, flag.RunTimeMaxMemory,
|
flag.RunTimeMinCPU, flag.RunTimeMaxCPU, flag.RunTimeMinMemory, flag.RunTimeMaxMemory,
|
||||||
flag.EnvTerminationGracePeriod, flag.EnvKeepArchive, flag.NamespaceEnvironment, flag.EnvExternalNetwork},
|
flag.EnvTerminationGracePeriod, flag.EnvKeepArchive,
|
||||||
|
flag.NamespaceEnvironment, flag.EnvExternalNetwork,
|
||||||
|
flag.Labels, flag.Annotation},
|
||||||
})
|
})
|
||||||
|
|
||||||
deleteCmd := &cobra.Command{
|
deleteCmd := &cobra.Command{
|
||||||
|
|||||||
@@ -179,6 +179,10 @@ func createEnvironmentFromCmd(input cli.Input) (*fv1.Environment, error) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = util.ApplyLabelsAndAnnotations(input, &env.ObjectMeta)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
err = env.Validate()
|
err = env.Validate()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fv1.AggregateValidationErrors("Environment", err)
|
return nil, fv1.AggregateValidationErrors("Environment", err)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import (
|
|||||||
"github.com/fission/fission/pkg/fission-cli/cmd"
|
"github.com/fission/fission/pkg/fission-cli/cmd"
|
||||||
"github.com/fission/fission/pkg/fission-cli/console"
|
"github.com/fission/fission/pkg/fission-cli/console"
|
||||||
flagkey "github.com/fission/fission/pkg/fission-cli/flag/key"
|
flagkey "github.com/fission/fission/pkg/fission-cli/flag/key"
|
||||||
|
"github.com/fission/fission/pkg/fission-cli/util"
|
||||||
"github.com/fission/fission/pkg/utils"
|
"github.com/fission/fission/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,6 +67,11 @@ func (opts *UpdateSubCommand) complete(input cli.Input) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
opts.env = env
|
opts.env = env
|
||||||
|
|
||||||
|
err = util.ApplyLabelsAndAnnotations(input, &opts.env.ObjectMeta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func Commands() *cobra.Command {
|
|||||||
flag.FnExecutorType, flag.FnCfgMap, flag.FnSecret,
|
flag.FnExecutorType, flag.FnCfgMap, flag.FnSecret,
|
||||||
flag.FnSpecializationTimeout, flag.FnExecutionTimeout,
|
flag.FnSpecializationTimeout, flag.FnExecutionTimeout,
|
||||||
flag.FnIdleTimeout, flag.FnConcurrency, flag.FnRequestsPerPod,
|
flag.FnIdleTimeout, flag.FnConcurrency, flag.FnRequestsPerPod,
|
||||||
flag.FnOnceOnly,
|
flag.FnOnceOnly, flag.Labels, flag.Annotation,
|
||||||
|
|
||||||
// TODO retired pkg & trigger related flags from function cmd
|
// TODO retired pkg & trigger related flags from function cmd
|
||||||
flag.PkgCode, flag.PkgSrcArchive, flag.PkgDeployArchive,
|
flag.PkgCode, flag.PkgSrcArchive, flag.PkgDeployArchive,
|
||||||
@@ -88,7 +88,7 @@ func Commands() *cobra.Command {
|
|||||||
flag.FnExecutorType, flag.FnSecret, flag.FnCfgMap,
|
flag.FnExecutorType, flag.FnSecret, flag.FnCfgMap,
|
||||||
flag.FnSpecializationTimeout, flag.FnExecutionTimeout,
|
flag.FnSpecializationTimeout, flag.FnExecutionTimeout,
|
||||||
flag.FnIdleTimeout, flag.FnConcurrency, flag.FnRequestsPerPod,
|
flag.FnIdleTimeout, flag.FnConcurrency, flag.FnRequestsPerPod,
|
||||||
flag.FnOnceOnly,
|
flag.FnOnceOnly, flag.Labels, flag.Annotation,
|
||||||
|
|
||||||
flag.PkgCode, flag.PkgSrcArchive, flag.PkgDeployArchive,
|
flag.PkgCode, flag.PkgSrcArchive, flag.PkgDeployArchive,
|
||||||
flag.PkgSrcChecksum, flag.PkgDeployChecksum, flag.PkgInsecure,
|
flag.PkgSrcChecksum, flag.PkgDeployChecksum, flag.PkgInsecure,
|
||||||
|
|||||||
@@ -309,6 +309,11 @@ func (opts *CreateSubCommand) complete(input cli.Input) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = util.ApplyLabelsAndAnnotations(input, &opts.function.ObjectMeta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,11 @@ func (opts *UpdateSubCommand) complete(input cli.Input) error {
|
|||||||
|
|
||||||
opts.function = function
|
opts.function = function
|
||||||
|
|
||||||
|
err = util.ApplyLabelsAndAnnotations(input, &opts.function.ObjectMeta)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ var (
|
|||||||
|
|
||||||
KubeContext = Flag{Type: String, Name: flagkey.KubeContext, Usage: "Kubernetes context to be used for the execution of Fission commands", DefaultValue: ""}
|
KubeContext = Flag{Type: String, Name: flagkey.KubeContext, Usage: "Kubernetes context to be used for the execution of Fission commands", DefaultValue: ""}
|
||||||
|
|
||||||
|
Labels = Flag{Type: String, Name: flagkey.Labels, Usage: "Comma separated labels to apply to the function. Eg. --labels=\"environment=dev,application=analytics\""}
|
||||||
|
Annotation = Flag{Type: StringSlice, Name: flagkey.Annotation, Usage: "Annotation to apply to the function. To mention multiple annotations --annotation=\"abc.com/team=dev\" --annotation=\"foo=bar\""}
|
||||||
|
|
||||||
NamespaceFunction = Flag{Type: String, Name: flagkey.NamespaceFunction, Aliases: []string{"fns"}, Usage: "Namespace for function object", DefaultValue: metav1.NamespaceDefault}
|
NamespaceFunction = Flag{Type: String, Name: flagkey.NamespaceFunction, Aliases: []string{"fns"}, Usage: "Namespace for function object", DefaultValue: metav1.NamespaceDefault}
|
||||||
NamespaceEnvironment = Flag{Type: String, Name: flagkey.NamespaceEnvironment, Aliases: []string{"envns"}, Usage: "Namespace for environment object", DefaultValue: metav1.NamespaceDefault}
|
NamespaceEnvironment = Flag{Type: String, Name: flagkey.NamespaceEnvironment, Aliases: []string{"envns"}, Usage: "Namespace for environment object", DefaultValue: metav1.NamespaceDefault}
|
||||||
NamespacePackage = Flag{Type: String, Name: flagkey.NamespacePackage, Aliases: []string{"pkgns"}, Usage: "Namespace for package object", DefaultValue: metav1.NamespaceDefault}
|
NamespacePackage = Flag{Type: String, Name: flagkey.NamespacePackage, Aliases: []string{"pkgns"}, Usage: "Namespace for package object", DefaultValue: metav1.NamespaceDefault}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ const (
|
|||||||
force = "force"
|
force = "force"
|
||||||
Output = "output"
|
Output = "output"
|
||||||
|
|
||||||
|
Labels = "labels"
|
||||||
|
Annotation = "annotation"
|
||||||
|
|
||||||
NamespaceFunction = "fnNamespace"
|
NamespaceFunction = "fnNamespace"
|
||||||
NamespaceEnvironment = "envNamespace"
|
NamespaceEnvironment = "envNamespace"
|
||||||
NamespacePackage = "pkgNamespace"
|
NamespacePackage = "pkgNamespace"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import (
|
|||||||
v1 "k8s.io/api/core/v1"
|
v1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/apimachinery/pkg/api/resource"
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/labels"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
restclient "k8s.io/client-go/rest"
|
restclient "k8s.io/client-go/rest"
|
||||||
"k8s.io/client-go/tools/clientcmd"
|
"k8s.io/client-go/tools/clientcmd"
|
||||||
@@ -350,3 +351,52 @@ func UrlForFunction(name, namespace string) string {
|
|||||||
}
|
}
|
||||||
return fmt.Sprintf("%v/%v", prefix, name)
|
return fmt.Sprintf("%v/%v", prefix, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseAnnotations(annotations []string) (map[string]string, error) {
|
||||||
|
var invalidAnnotations string
|
||||||
|
annotationMap := make(map[string]string)
|
||||||
|
for _, arg := range annotations {
|
||||||
|
if strings.Contains(arg, "=") && arg[0] != '=' {
|
||||||
|
parts := strings.SplitN(arg, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
annotationMap[parts[0]] = parts[1]
|
||||||
|
} else {
|
||||||
|
if invalidAnnotations != "" {
|
||||||
|
invalidAnnotations = fmt.Sprintf("%s,%s", invalidAnnotations, arg)
|
||||||
|
} else {
|
||||||
|
invalidAnnotations = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if invalidAnnotations != "" {
|
||||||
|
invalidAnnotations = fmt.Sprintf("%s,%s", invalidAnnotations, arg)
|
||||||
|
} else {
|
||||||
|
invalidAnnotations = arg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if invalidAnnotations != "" {
|
||||||
|
return nil, errors.Errorf("invalid annotations: %s", invalidAnnotations)
|
||||||
|
}
|
||||||
|
return annotationMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ApplyLabelsAndAnnotations(input cli.Input, objectMeta *metav1.ObjectMeta) error {
|
||||||
|
labelStr := input.String(flagkey.Labels)
|
||||||
|
if labelStr != "" {
|
||||||
|
set, err := labels.ConvertSelectorToLabelsMap(labelStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objectMeta.Labels = set
|
||||||
|
}
|
||||||
|
annotationStr := input.StringSlice(flagkey.Annotation)
|
||||||
|
if len(annotationStr) > 0 {
|
||||||
|
set, err := ParseAnnotations(annotationStr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
objectMeta.Annotations = set
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2021 The Fission Authors.
|
||||||
|
|
||||||
|
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 maps
|
||||||
|
|
||||||
|
func CopyStringMap(m map[string]string) map[string]string {
|
||||||
|
n := make(map[string]string)
|
||||||
|
for k, v := range m {
|
||||||
|
n[k] = v
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user