2021-03-26 15:24:19 +08:00
|
|
|
/*
|
|
|
|
Copyright 2021 The KubeVela 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.
|
|
|
|
*/
|
|
|
|
|
2020-11-01 10:44:17 +08:00
|
|
|
package utils
|
|
|
|
|
|
|
|
import (
|
2021-02-21 15:10:15 +08:00
|
|
|
"context"
|
2020-11-01 10:44:17 +08:00
|
|
|
"encoding/json"
|
|
|
|
"fmt"
|
2021-02-21 15:10:15 +08:00
|
|
|
"reflect"
|
2020-11-01 10:44:17 +08:00
|
|
|
"strconv"
|
2020-12-04 10:03:25 +08:00
|
|
|
"strings"
|
2021-02-20 04:11:26 +08:00
|
|
|
"time"
|
2020-11-01 10:44:17 +08:00
|
|
|
|
2021-05-28 12:12:39 +08:00
|
|
|
runtimev1alpha1 "github.com/crossplane/crossplane-runtime/apis/core/v1alpha1"
|
2020-11-01 10:44:17 +08:00
|
|
|
"github.com/crossplane/crossplane-runtime/pkg/fieldpath"
|
2020-12-04 10:03:25 +08:00
|
|
|
mapset "github.com/deckarep/golang-set"
|
2021-03-16 11:39:55 +08:00
|
|
|
"github.com/mitchellh/hashstructure/v2"
|
2021-03-15 15:54:43 +08:00
|
|
|
appsv1 "k8s.io/api/apps/v1"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
|
|
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
2021-04-08 20:35:21 +08:00
|
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
2020-11-01 10:44:17 +08:00
|
|
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
2021-05-15 11:43:33 +08:00
|
|
|
"k8s.io/apimachinery/pkg/runtime"
|
2021-04-08 20:35:21 +08:00
|
|
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
2020-11-01 10:44:17 +08:00
|
|
|
"k8s.io/apimachinery/pkg/util/intstr"
|
2021-02-20 04:11:26 +08:00
|
|
|
"k8s.io/apimachinery/pkg/util/wait"
|
2021-02-21 15:10:15 +08:00
|
|
|
"k8s.io/client-go/util/retry"
|
2021-06-03 12:07:30 +08:00
|
|
|
"k8s.io/klog/v2"
|
2021-02-21 15:10:15 +08:00
|
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
2020-11-26 12:27:25 +08:00
|
|
|
|
2021-04-08 20:35:21 +08:00
|
|
|
commontypes "github.com/oam-dev/kubevela/apis/core.oam.dev/common"
|
2020-11-26 12:27:25 +08:00
|
|
|
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1alpha2"
|
2021-03-25 08:15:20 +08:00
|
|
|
"github.com/oam-dev/kubevela/apis/core.oam.dev/v1beta1"
|
2020-12-04 10:03:25 +08:00
|
|
|
"github.com/oam-dev/kubevela/pkg/controller/common"
|
2021-06-02 15:37:06 +08:00
|
|
|
"github.com/oam-dev/kubevela/pkg/cue/packages"
|
2020-11-26 12:27:25 +08:00
|
|
|
"github.com/oam-dev/kubevela/pkg/oam"
|
2021-04-08 20:35:21 +08:00
|
|
|
"github.com/oam-dev/kubevela/pkg/oam/discoverymapper"
|
2021-02-21 15:10:15 +08:00
|
|
|
"github.com/oam-dev/kubevela/pkg/oam/util"
|
2020-11-01 10:44:17 +08:00
|
|
|
)
|
|
|
|
|
2021-02-20 04:11:26 +08:00
|
|
|
// DefaultBackoff is the backoff we use in controller
|
|
|
|
var DefaultBackoff = wait.Backoff{
|
|
|
|
Duration: 1 * time.Second,
|
|
|
|
Factor: 2,
|
|
|
|
Steps: 5,
|
|
|
|
Jitter: 0.1,
|
|
|
|
}
|
|
|
|
|
2020-11-18 16:08:16 +08:00
|
|
|
// LabelPodSpecable defines whether a workload has podSpec or not.
|
2020-11-17 17:46:56 +08:00
|
|
|
const LabelPodSpecable = "workload.oam.dev/podspecable"
|
|
|
|
|
2020-12-04 10:03:25 +08:00
|
|
|
// allBuiltinCapabilities includes all builtin controllers
|
|
|
|
// TODO(zzxwill) needs to automatically discovery all controllers
|
|
|
|
var allBuiltinCapabilities = mapset.NewSet(common.MetricsControllerName, common.PodspecWorkloadControllerName,
|
|
|
|
common.RouteControllerName, common.AutoscaleControllerName)
|
|
|
|
|
2020-11-18 16:08:16 +08:00
|
|
|
// GetPodSpecPath get podSpec field and label
|
2020-11-01 10:44:17 +08:00
|
|
|
func GetPodSpecPath(workloadDef *v1alpha2.WorkloadDefinition) (string, bool) {
|
|
|
|
if workloadDef.Spec.PodSpecPath != "" {
|
|
|
|
return workloadDef.Spec.PodSpecPath, true
|
|
|
|
}
|
|
|
|
if workloadDef.Labels == nil {
|
|
|
|
return "", false
|
|
|
|
}
|
2020-11-17 17:46:56 +08:00
|
|
|
podSpecable, ok := workloadDef.Labels[LabelPodSpecable]
|
2020-11-01 10:44:17 +08:00
|
|
|
if !ok {
|
|
|
|
return "", false
|
|
|
|
}
|
|
|
|
ok, _ = strconv.ParseBool(podSpecable)
|
|
|
|
return "", ok
|
|
|
|
}
|
|
|
|
|
2020-11-18 16:08:16 +08:00
|
|
|
// DiscoveryFromPodSpec will discover pods from podSpec
|
2020-11-01 10:44:17 +08:00
|
|
|
func DiscoveryFromPodSpec(w *unstructured.Unstructured, fieldPath string) ([]intstr.IntOrString, error) {
|
|
|
|
paved := fieldpath.Pave(w.Object)
|
|
|
|
obj, err := paved.GetValue(fieldPath)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
data, err := json.Marshal(obj)
|
|
|
|
if err != nil {
|
2020-11-26 15:12:03 +08:00
|
|
|
return nil, fmt.Errorf("discovery podSpec from %s in workload %v err %w", fieldPath, w.GetName(), err)
|
2020-11-01 10:44:17 +08:00
|
|
|
}
|
2021-03-15 15:54:43 +08:00
|
|
|
var spec corev1.PodSpec
|
2020-11-01 10:44:17 +08:00
|
|
|
err = json.Unmarshal(data, &spec)
|
|
|
|
if err != nil {
|
2020-11-26 15:12:03 +08:00
|
|
|
return nil, fmt.Errorf("discovery podSpec from %s in workload %v err %w", fieldPath, w.GetName(), err)
|
2020-11-01 10:44:17 +08:00
|
|
|
}
|
|
|
|
ports := getContainerPorts(spec.Containers)
|
|
|
|
if len(ports) == 0 {
|
|
|
|
return nil, fmt.Errorf("no port found in podSpec %v", w.GetName())
|
|
|
|
}
|
|
|
|
return ports, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// DiscoveryFromPodTemplate not only discovery port, will also use labels in podTemplate
|
|
|
|
func DiscoveryFromPodTemplate(w *unstructured.Unstructured, fields ...string) ([]intstr.IntOrString, map[string]string, error) {
|
|
|
|
obj, found, _ := unstructured.NestedMap(w.Object, fields...)
|
|
|
|
if !found {
|
|
|
|
return nil, nil, fmt.Errorf("not have spec.template in workload %v", w.GetName())
|
|
|
|
}
|
|
|
|
data, err := json.Marshal(obj)
|
|
|
|
if err != nil {
|
2020-11-26 15:12:03 +08:00
|
|
|
return nil, nil, fmt.Errorf("workload %v convert object err %w", w.GetName(), err)
|
2020-11-01 10:44:17 +08:00
|
|
|
}
|
2021-03-15 15:54:43 +08:00
|
|
|
var spec corev1.PodTemplateSpec
|
2020-11-01 10:44:17 +08:00
|
|
|
err = json.Unmarshal(data, &spec)
|
|
|
|
if err != nil {
|
2020-11-26 15:12:03 +08:00
|
|
|
return nil, nil, fmt.Errorf("workload %v convert object to PodTemplate err %w", w.GetName(), err)
|
2020-11-01 10:44:17 +08:00
|
|
|
}
|
|
|
|
ports := getContainerPorts(spec.Spec.Containers)
|
|
|
|
if len(ports) == 0 {
|
|
|
|
return nil, nil, fmt.Errorf("no port found in workload %v", w.GetName())
|
|
|
|
}
|
|
|
|
return ports, spec.Labels, nil
|
|
|
|
}
|
|
|
|
|
2021-03-15 15:54:43 +08:00
|
|
|
func getContainerPorts(cs []corev1.Container) []intstr.IntOrString {
|
2020-11-01 10:44:17 +08:00
|
|
|
var ports []intstr.IntOrString
|
2020-11-26 15:12:03 +08:00
|
|
|
// TODO(wonderflow): exclude some sidecars
|
2020-11-01 10:44:17 +08:00
|
|
|
for _, container := range cs {
|
|
|
|
for _, port := range container.Ports {
|
|
|
|
ports = append(ports, intstr.FromInt(int(port.ContainerPort)))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return ports
|
|
|
|
}
|
2020-11-06 15:19:05 +08:00
|
|
|
|
|
|
|
// SelectOAMAppLabelsWithoutRevision will filter and return OAM app labels only, if no labels, return the original one.
|
|
|
|
func SelectOAMAppLabelsWithoutRevision(labels map[string]string) map[string]string {
|
|
|
|
newLabel := make(map[string]string)
|
|
|
|
for k, v := range labels {
|
|
|
|
// Note: we don't include revision label by design
|
|
|
|
// if we want to distinguish with different revisions, we should include it in other function.
|
|
|
|
if k != oam.LabelAppName && k != oam.LabelAppComponent {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
newLabel[k] = v
|
|
|
|
}
|
2020-11-18 05:43:38 +08:00
|
|
|
if len(newLabel) == 0 {
|
2020-11-06 15:19:05 +08:00
|
|
|
return labels
|
|
|
|
}
|
|
|
|
return newLabel
|
|
|
|
}
|
2020-12-04 10:03:25 +08:00
|
|
|
|
|
|
|
// CheckDisabledCapabilities checks whether the disabled capability controllers are valid
|
|
|
|
func CheckDisabledCapabilities(disableCaps string) error {
|
|
|
|
switch disableCaps {
|
|
|
|
case common.DisableNoneCaps:
|
|
|
|
return nil
|
|
|
|
case common.DisableAllCaps:
|
|
|
|
return nil
|
|
|
|
default:
|
|
|
|
for _, c := range strings.Split(disableCaps, ",") {
|
|
|
|
if !allBuiltinCapabilities.Contains(c) {
|
|
|
|
return fmt.Errorf("%s in disable caps list is not built-in", c)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// StoreInSet stores items in Set
|
|
|
|
func StoreInSet(disableCaps string) mapset.Set {
|
|
|
|
var disableSlice []interface{}
|
|
|
|
for _, c := range strings.Split(disableCaps, ",") {
|
|
|
|
disableSlice = append(disableSlice, c)
|
|
|
|
}
|
|
|
|
return mapset.NewSetFromSlice(disableSlice)
|
|
|
|
}
|
2021-02-21 15:10:15 +08:00
|
|
|
|
2021-03-11 15:56:38 +08:00
|
|
|
// GetAppNextRevision will generate the next revision name and revision number for application
|
2021-03-25 08:15:20 +08:00
|
|
|
func GetAppNextRevision(app *v1beta1.Application) (string, int64) {
|
2021-03-10 16:47:17 +08:00
|
|
|
if app == nil {
|
|
|
|
// should never happen
|
|
|
|
return "", 0
|
|
|
|
}
|
|
|
|
var nextRevision int64 = 1
|
|
|
|
if app.Status.LatestRevision != nil {
|
2021-03-20 03:30:31 +08:00
|
|
|
// revision will always bump and increment no matter what the way user is running.
|
|
|
|
nextRevision = app.Status.LatestRevision.Revision + 1
|
2021-03-10 16:47:17 +08:00
|
|
|
}
|
|
|
|
return ConstructRevisionName(app.Name, nextRevision), nextRevision
|
|
|
|
}
|
|
|
|
|
2021-02-21 15:10:15 +08:00
|
|
|
// ConstructRevisionName will generate a revisionName given the componentName and revision
|
|
|
|
// will be <componentName>-v<RevisionNumber>, for example: comp-v1
|
|
|
|
func ConstructRevisionName(componentName string, revision int64) string {
|
|
|
|
return strings.Join([]string{componentName, fmt.Sprintf("v%d", revision)}, "-")
|
|
|
|
}
|
|
|
|
|
|
|
|
// ExtractComponentName will extract the componentName from a revisionName
|
|
|
|
func ExtractComponentName(revisionName string) string {
|
|
|
|
splits := strings.Split(revisionName, "-")
|
|
|
|
return strings.Join(splits[0:len(splits)-1], "-")
|
|
|
|
}
|
|
|
|
|
|
|
|
// ExtractRevision will extract the revision from a revisionName
|
|
|
|
func ExtractRevision(revisionName string) (int, error) {
|
|
|
|
splits := strings.Split(revisionName, "-")
|
|
|
|
// the revision is the last string without the prefix "v"
|
|
|
|
return strconv.Atoi(strings.TrimPrefix(splits[len(splits)-1], "v"))
|
|
|
|
}
|
|
|
|
|
|
|
|
// CompareWithRevision compares a component's spec with the component's latest revision content
|
2021-06-03 12:07:30 +08:00
|
|
|
func CompareWithRevision(ctx context.Context, c client.Client, componentName, nameSpace,
|
2021-02-21 15:10:15 +08:00
|
|
|
latestRevision string, curCompSpec *v1alpha2.ComponentSpec) (bool, error) {
|
2021-03-15 15:54:43 +08:00
|
|
|
oldRev := &appsv1.ControllerRevision{}
|
2021-02-21 15:10:15 +08:00
|
|
|
// retry on NotFound since we update the component last revision first
|
|
|
|
err := wait.ExponentialBackoff(retry.DefaultBackoff, func() (bool, error) {
|
|
|
|
err := c.Get(ctx, client.ObjectKey{Namespace: nameSpace, Name: latestRevision}, oldRev)
|
2021-03-15 15:54:43 +08:00
|
|
|
if err != nil && !kerrors.IsNotFound(err) {
|
2021-06-03 12:07:30 +08:00
|
|
|
klog.InfoS(fmt.Sprintf("get old controllerRevision %s error %v",
|
2021-02-21 15:10:15 +08:00
|
|
|
latestRevision, err), "componentName", componentName)
|
|
|
|
return false, err
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
})
|
|
|
|
if err != nil {
|
|
|
|
return true, err
|
|
|
|
}
|
|
|
|
oldComp, err := util.UnpackRevisionData(oldRev)
|
|
|
|
if err != nil {
|
2021-06-03 12:07:30 +08:00
|
|
|
klog.InfoS("Unmarshal old controllerRevision", latestRevision, "error", err, "componentName", componentName)
|
2021-02-21 15:10:15 +08:00
|
|
|
return true, err
|
|
|
|
}
|
|
|
|
if reflect.DeepEqual(curCompSpec, &oldComp.Spec) {
|
|
|
|
// no need to create a new revision
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
}
|
2021-03-16 11:39:55 +08:00
|
|
|
|
|
|
|
// ComputeSpecHash computes the hash value of a k8s resource spec
|
|
|
|
func ComputeSpecHash(spec interface{}) (string, error) {
|
|
|
|
// compute a hash value of any resource spec
|
|
|
|
specHash, err := hashstructure.Hash(spec, hashstructure.FormatV2, nil)
|
|
|
|
if err != nil {
|
|
|
|
return "", err
|
|
|
|
}
|
|
|
|
specHashLabel := strconv.FormatUint(specHash, 16)
|
|
|
|
return specHashLabel, nil
|
|
|
|
}
|
2021-04-08 20:35:21 +08:00
|
|
|
|
|
|
|
// RefreshPackageDiscover help refresh package discover
|
2021-05-15 11:43:33 +08:00
|
|
|
func RefreshPackageDiscover(ctx context.Context, k8sClient client.Client, dm discoverymapper.DiscoveryMapper,
|
2021-06-02 15:37:06 +08:00
|
|
|
pd *packages.PackageDiscover, definition runtime.Object) error {
|
2021-04-08 20:35:21 +08:00
|
|
|
var gvk schema.GroupVersionKind
|
|
|
|
var err error
|
2021-05-15 11:43:33 +08:00
|
|
|
switch def := definition.(type) {
|
|
|
|
case *v1beta1.ComponentDefinition:
|
|
|
|
if def.Spec.Workload.Definition == (commontypes.WorkloadGVK{}) {
|
|
|
|
workloadDef := new(v1beta1.WorkloadDefinition)
|
|
|
|
err = k8sClient.Get(ctx, client.ObjectKey{Name: def.Spec.Workload.Type, Namespace: def.Namespace}, workloadDef)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
gvk, err = util.GetGVKFromDefinition(dm, workloadDef.Spec.Reference)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
gv, err := schema.ParseGroupVersion(def.Spec.Workload.Definition.APIVersion)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
gvk = gv.WithKind(def.Spec.Workload.Definition.Kind)
|
2021-04-08 20:35:21 +08:00
|
|
|
}
|
2021-05-15 11:43:33 +08:00
|
|
|
case *v1beta1.TraitDefinition:
|
|
|
|
gvk, err = util.GetGVKFromDefinition(dm, def.Spec.Reference)
|
2021-04-08 20:35:21 +08:00
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-05-28 12:12:39 +08:00
|
|
|
case *v1beta1.PolicyDefinition:
|
|
|
|
gvk, err = util.GetGVKFromDefinition(dm, def.Spec.Reference)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
case *v1beta1.WorkflowStepDefinition:
|
|
|
|
gvk, err = util.GetGVKFromDefinition(dm, def.Spec.Reference)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
2021-05-15 11:43:33 +08:00
|
|
|
default:
|
2021-04-08 20:35:21 +08:00
|
|
|
}
|
|
|
|
targetGVK := metav1.GroupVersionKind{
|
|
|
|
Group: gvk.Group,
|
|
|
|
Version: gvk.Version,
|
|
|
|
Kind: gvk.Kind,
|
|
|
|
}
|
|
|
|
if exist := pd.Exist(targetGVK); exist {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := pd.RefreshKubePackagesFromCluster(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Test whether the refresh is successful
|
|
|
|
if exist := pd.Exist(targetGVK); !exist {
|
|
|
|
return fmt.Errorf("get CRD %s error", targetGVK.String())
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
2021-04-16 16:46:41 +08:00
|
|
|
|
|
|
|
// CheckAppDeploymentUsingAppRevision get all appDeployments using appRevisions related the app
|
|
|
|
func CheckAppDeploymentUsingAppRevision(ctx context.Context, c client.Reader, appNs string, appName string) ([]string, error) {
|
|
|
|
deployOpts := []client.ListOption{
|
|
|
|
client.InNamespace(appNs),
|
|
|
|
}
|
|
|
|
var res []string
|
|
|
|
ads := new(v1beta1.AppDeploymentList)
|
|
|
|
if err := c.List(ctx, ads, deployOpts...); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if len(ads.Items) == 0 {
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
relatedRevs := new(v1beta1.ApplicationRevisionList)
|
|
|
|
revOpts := []client.ListOption{
|
|
|
|
client.InNamespace(appNs),
|
|
|
|
client.MatchingLabels{oam.LabelAppName: appName},
|
|
|
|
}
|
|
|
|
if err := c.List(ctx, relatedRevs, revOpts...); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if len(relatedRevs.Items) == 0 {
|
|
|
|
return res, nil
|
|
|
|
}
|
|
|
|
revName := map[string]bool{}
|
|
|
|
for _, rev := range relatedRevs.Items {
|
|
|
|
if len(rev.Name) != 0 {
|
|
|
|
revName[rev.Name] = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for _, d := range ads.Items {
|
|
|
|
for _, dr := range d.Spec.AppRevisions {
|
|
|
|
if len(dr.RevisionName) != 0 {
|
|
|
|
if revName[dr.RevisionName] {
|
|
|
|
res = append(res, dr.RevisionName)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return res, nil
|
|
|
|
}
|
2021-05-28 12:12:39 +08:00
|
|
|
|
|
|
|
// GetUnstructuredObjectStatusCondition returns the status.condition with matching condType from an unstructured object.
|
|
|
|
func GetUnstructuredObjectStatusCondition(obj *unstructured.Unstructured, condType string) (*runtimev1alpha1.Condition, bool, error) {
|
|
|
|
cs, found, err := unstructured.NestedSlice(obj.Object, "status", "conditions")
|
|
|
|
if err != nil {
|
|
|
|
return nil, false, err
|
|
|
|
}
|
|
|
|
if !found {
|
|
|
|
return nil, false, nil
|
|
|
|
}
|
|
|
|
for _, c := range cs {
|
|
|
|
b, err := json.Marshal(c)
|
|
|
|
if err != nil {
|
|
|
|
return nil, false, err
|
|
|
|
}
|
|
|
|
condObj := &runtimev1alpha1.Condition{}
|
|
|
|
err = json.Unmarshal(b, condObj)
|
|
|
|
if err != nil {
|
|
|
|
return nil, false, err
|
|
|
|
}
|
|
|
|
|
|
|
|
if string(condObj.Type) != condType {
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
return condObj, true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
return nil, false, nil
|
|
|
|
}
|