github.com/crossplane/upjet@v1.3.0/pkg/migration/kubernetes.go (about)

     1  // SPDX-FileCopyrightText: 2023 The Crossplane Authors <https://crossplane.io>
     2  //
     3  // SPDX-License-Identifier: Apache-2.0
     4  
     5  package migration
     6  
     7  import (
     8  	"context"
     9  	"fmt"
    10  	"path/filepath"
    11  	"regexp"
    12  	"strings"
    13  	"time"
    14  
    15  	"github.com/pkg/errors"
    16  	"k8s.io/apimachinery/pkg/api/meta"
    17  	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
    18  	"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    19  	"k8s.io/apimachinery/pkg/runtime/schema"
    20  	"k8s.io/cli-runtime/pkg/resource"
    21  	"k8s.io/client-go/discovery"
    22  	"k8s.io/client-go/discovery/cached/disk"
    23  	"k8s.io/client-go/dynamic"
    24  	"k8s.io/client-go/rest"
    25  	"k8s.io/client-go/restmapper"
    26  	"k8s.io/client-go/tools/clientcmd"
    27  	"k8s.io/client-go/util/homedir"
    28  )
    29  
    30  const (
    31  	errKubernetesSourceInit = "failed to initialize the migration Kubernetes source"
    32  	errCategoryGetFmt       = "failed to get resources of category %q"
    33  )
    34  
    35  var (
    36  	_               Source = &KubernetesSource{}
    37  	defaultCacheDir        = filepath.Join(homedir.HomeDir(), ".kube", "cache")
    38  )
    39  
    40  // KubernetesSource is a source implementation to read resources from Kubernetes
    41  // cluster.
    42  type KubernetesSource struct {
    43  	registry              *Registry
    44  	categories            []Category
    45  	index                 int
    46  	items                 []UnstructuredWithMetadata
    47  	dynamicClient         dynamic.Interface
    48  	cachedDiscoveryClient discovery.CachedDiscoveryInterface
    49  	restMapper            meta.RESTMapper
    50  	categoryExpander      restmapper.CategoryExpander
    51  	cacheDir              string
    52  	restConfig            *rest.Config
    53  }
    54  
    55  // KubernetesSourceOption sets an option for a KubernetesSource.
    56  type KubernetesSourceOption func(source *KubernetesSource)
    57  
    58  // WithCacheDir sets the cache directory for the disk cached discovery client
    59  // used by a KubernetesSource.
    60  func WithCacheDir(cacheDir string) KubernetesSourceOption {
    61  	return func(s *KubernetesSource) {
    62  		s.cacheDir = cacheDir
    63  	}
    64  }
    65  
    66  // WithRegistry configures a KubernetesSource to use the specified registry
    67  // for determining the GVKs of resources which will be read from the
    68  // Kubernetes API server.
    69  func WithRegistry(r *Registry) KubernetesSourceOption {
    70  	return func(s *KubernetesSource) {
    71  		s.registry = r
    72  	}
    73  }
    74  
    75  // WithCategories configures a KubernetesSource so that it will fetch
    76  // all resources belonging to the specified categories.
    77  func WithCategories(c []Category) KubernetesSourceOption {
    78  	return func(s *KubernetesSource) {
    79  		s.categories = c
    80  	}
    81  }
    82  
    83  // NewKubernetesSourceFromKubeConfig initializes a new KubernetesSource using
    84  // the specified kube config file and KubernetesSourceOptions.
    85  func NewKubernetesSourceFromKubeConfig(kubeconfigPath string, opts ...KubernetesSourceOption) (*KubernetesSource, error) {
    86  	ks := &KubernetesSource{}
    87  	for _, o := range opts {
    88  		o(ks)
    89  	}
    90  
    91  	var err error
    92  	ks.restConfig, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
    93  	if err != nil {
    94  		return nil, errors.Wrap(err, "cannot create rest config object")
    95  	}
    96  	ks.restConfig.ContentConfig = resource.UnstructuredPlusDefaultContentConfig()
    97  
    98  	ks.dynamicClient, err = InitializeDynamicClient(kubeconfigPath)
    99  	if err != nil {
   100  		return nil, errors.Wrapf(err, "failed to initialize a Kubernetes dynamic client from kubeconfig: %s", kubeconfigPath)
   101  	}
   102  	ks.cachedDiscoveryClient, err = InitializeDiscoveryClient(kubeconfigPath, ks.cacheDir)
   103  	if err != nil {
   104  		return nil, errors.Wrapf(err, "failed to initialize a Kubernetes discovery client from kubeconfig: %s", kubeconfigPath)
   105  	}
   106  	return ks, errors.Wrap(ks.init(), errKubernetesSourceInit)
   107  }
   108  
   109  // NewKubernetesSource returns a KubernetesSource
   110  // DynamicClient is used here to query resources.
   111  // Elements of gvks (slice of GroupVersionKind) are passed to the Dynamic Client
   112  // in a loop to get list of resources.
   113  // An example element of gvks slice:
   114  // Group:   "ec2.aws.upbound.io",
   115  // Version: "v1beta1",
   116  // Kind:    "VPC",
   117  func NewKubernetesSource(dynamicClient dynamic.Interface, discoveryClient discovery.CachedDiscoveryInterface, opts ...KubernetesSourceOption) (*KubernetesSource, error) {
   118  	ks := &KubernetesSource{
   119  		dynamicClient:         dynamicClient,
   120  		cachedDiscoveryClient: discoveryClient,
   121  	}
   122  	for _, o := range opts {
   123  		o(ks)
   124  	}
   125  	return ks, errors.Wrap(ks.init(), errKubernetesSourceInit)
   126  }
   127  
   128  func (ks *KubernetesSource) init() error {
   129  	ks.restMapper = restmapper.NewDeferredDiscoveryRESTMapper(ks.cachedDiscoveryClient)
   130  	ks.categoryExpander = restmapper.NewDiscoveryCategoryExpander(ks.cachedDiscoveryClient)
   131  
   132  	for _, c := range ks.categories {
   133  		if err := ks.getCategoryResources(c); err != nil {
   134  			return errors.Wrapf(err, "cannot get resources of the category: %s", c)
   135  		}
   136  	}
   137  
   138  	if ks.registry == nil {
   139  		return nil
   140  	}
   141  	if err := ks.getGVKResources(ks.registry.claimTypes, CategoryClaim); err != nil {
   142  		return errors.Wrap(err, "cannot get claims")
   143  	}
   144  	if err := ks.getGVKResources(ks.registry.compositeTypes, CategoryComposite); err != nil {
   145  		return errors.Wrap(err, "cannot get composites")
   146  	}
   147  	if err := ks.getGVKResources(ks.registry.GetCompositionGVKs(), CategoryComposition); err != nil {
   148  		return errors.Wrap(err, "cannot get compositions")
   149  	}
   150  	if err := ks.getGVKResources(ks.registry.GetCrossplanePackageGVKs(), CategoryCrossplanePackage); err != nil {
   151  		return errors.Wrap(err, "cannot get Crossplane packages")
   152  	}
   153  	return errors.Wrap(ks.getGVKResources(ks.registry.GetManagedResourceGVKs(), CategoryManaged), "cannot get managed resources")
   154  }
   155  
   156  func (ks *KubernetesSource) getMappingFor(gr schema.GroupResource) (*meta.RESTMapping, error) {
   157  	r := fmt.Sprintf("%s.%s", gr.Resource, gr.Group)
   158  	fullySpecifiedGVR, groupResource := schema.ParseResourceArg(r)
   159  	gvk := schema.GroupVersionKind{}
   160  	if fullySpecifiedGVR != nil {
   161  		gvk, _ = ks.restMapper.KindFor(*fullySpecifiedGVR)
   162  	}
   163  	if gvk.Empty() {
   164  		gvk, _ = ks.restMapper.KindFor(groupResource.WithVersion(""))
   165  	}
   166  	if !gvk.Empty() {
   167  		return ks.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
   168  	}
   169  	fullySpecifiedGVK, groupKind := schema.ParseKindArg(r)
   170  	if fullySpecifiedGVK == nil {
   171  		gvk := groupKind.WithVersion("")
   172  		fullySpecifiedGVK = &gvk
   173  	}
   174  
   175  	if !fullySpecifiedGVK.Empty() {
   176  		if mapping, err := ks.restMapper.RESTMapping(fullySpecifiedGVK.GroupKind(), fullySpecifiedGVK.Version); err == nil {
   177  			return mapping, nil
   178  		}
   179  	}
   180  
   181  	mapping, err := ks.restMapper.RESTMapping(groupKind, gvk.Version)
   182  	if err != nil {
   183  		if meta.IsNoMatchError(err) {
   184  			return nil, errors.Errorf("the server doesn't have a resource type %q", groupResource.Resource)
   185  		}
   186  		return nil, err
   187  	}
   188  	return mapping, nil
   189  }
   190  
   191  // parts of this implement are taken from the implementation of
   192  // the "kubectl get" command:
   193  // https://github.com/kubernetes/kubernetes/tree/master/staging/src/k8s.io/kubectl/pkg/cmd/get
   194  func (ks *KubernetesSource) getCategoryResources(c Category) error {
   195  	if ks.restConfig == nil {
   196  		return errors.New("rest.Config not initialized")
   197  	}
   198  	grs, _ := ks.categoryExpander.Expand(c.String())
   199  	for _, gr := range grs {
   200  		mapping, err := ks.getMappingFor(gr)
   201  		if err != nil {
   202  			return errors.Wrapf(err, errCategoryGetFmt, c.String())
   203  		}
   204  		gv := mapping.GroupVersionKind.GroupVersion()
   205  		ks.restConfig.GroupVersion = &gv
   206  		if len(gv.Group) == 0 {
   207  			ks.restConfig.APIPath = "/api"
   208  		} else {
   209  			ks.restConfig.APIPath = "/apis"
   210  		}
   211  		client, err := rest.RESTClientFor(ks.restConfig)
   212  		if err != nil {
   213  			return errors.Wrapf(err, errCategoryGetFmt, c.String())
   214  		}
   215  		helper := resource.NewHelper(client, mapping)
   216  		list, err := helper.List("", mapping.GroupVersionKind.GroupVersion().String(), &metav1.ListOptions{})
   217  		if err != nil {
   218  			return errors.Wrapf(err, errCategoryGetFmt, c.String())
   219  		}
   220  		ul, ok := list.(*unstructured.UnstructuredList)
   221  		if !ok {
   222  			return errors.New("expecting list to be of type *unstructured.UnstructuredList")
   223  		}
   224  		for _, u := range ul.Items {
   225  			ks.items = append(ks.items, UnstructuredWithMetadata{
   226  				Object: u,
   227  				Metadata: Metadata{
   228  					Path:     string(u.GetUID()),
   229  					Category: c,
   230  				},
   231  			})
   232  		}
   233  	}
   234  	return nil
   235  }
   236  
   237  func (ks *KubernetesSource) getGVKResources(gvks []schema.GroupVersionKind, category Category) error {
   238  	processed := map[schema.GroupVersionKind]struct{}{}
   239  	for _, gvk := range gvks {
   240  		if _, ok := processed[gvk]; ok {
   241  			continue
   242  		}
   243  		m, err := ks.restMapper.RESTMapping(gvk.GroupKind(), gvk.Version)
   244  		if err != nil {
   245  			return errors.Wrapf(err, "cannot get REST mappings for GVK: %s", gvk.String())
   246  		}
   247  		if err := ks.getResourcesFor(m.Resource, category); err != nil {
   248  			return errors.Wrapf(err, "cannot get resources for GVK: %s", gvk.String())
   249  		}
   250  		processed[gvk] = struct{}{}
   251  	}
   252  	return nil
   253  }
   254  
   255  func (ks *KubernetesSource) getResourcesFor(gvr schema.GroupVersionResource, category Category) error {
   256  	ri := ks.dynamicClient.Resource(gvr)
   257  	unstructuredList, err := ri.List(context.TODO(), metav1.ListOptions{})
   258  	if err != nil {
   259  		return errors.Wrapf(err, "cannot list resources of GVR: %s", gvr.String())
   260  	}
   261  	for _, u := range unstructuredList.Items {
   262  		ks.items = append(ks.items, UnstructuredWithMetadata{
   263  			Object: u,
   264  			Metadata: Metadata{
   265  				Path:     string(u.GetUID()),
   266  				Category: category,
   267  			},
   268  		})
   269  	}
   270  	return nil
   271  }
   272  
   273  // HasNext checks the next item
   274  func (ks *KubernetesSource) HasNext() (bool, error) {
   275  	return ks.index < len(ks.items), nil
   276  }
   277  
   278  // Next returns the next item of slice
   279  func (ks *KubernetesSource) Next() (UnstructuredWithMetadata, error) {
   280  	if hasNext, _ := ks.HasNext(); hasNext {
   281  		item := ks.items[ks.index]
   282  		ks.index++
   283  		return item, nil
   284  	}
   285  	return UnstructuredWithMetadata{}, errors.New("no more elements")
   286  }
   287  
   288  // Reset resets the source so that resources can be reread from the beginning.
   289  func (ks *KubernetesSource) Reset() error {
   290  	ks.index = 0
   291  	return nil
   292  }
   293  
   294  // InitializeDynamicClient returns a dynamic client
   295  func InitializeDynamicClient(kubeconfigPath string) (dynamic.Interface, error) {
   296  	config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
   297  	if err != nil {
   298  		return nil, errors.Wrap(err, "cannot create rest config object")
   299  	}
   300  	dynamicClient, err := dynamic.NewForConfig(config)
   301  	if err != nil {
   302  		return nil, errors.Wrap(err, "cannot initialize dynamic client")
   303  	}
   304  	return dynamicClient, nil
   305  }
   306  
   307  func InitializeDiscoveryClient(kubeconfigPath, cacheDir string) (*disk.CachedDiscoveryClient, error) {
   308  	config, err := clientcmd.BuildConfigFromFlags("", kubeconfigPath)
   309  	if err != nil {
   310  		return nil, errors.Wrap(err, "cannot create rest config object")
   311  	}
   312  
   313  	if cacheDir == "" {
   314  		cacheDir = defaultCacheDir
   315  	}
   316  	httpCacheDir := filepath.Join(cacheDir, "http")
   317  	discoveryCacheDir := computeDiscoverCacheDir(filepath.Join(cacheDir, "discovery"), config.Host)
   318  	return disk.NewCachedDiscoveryClientForConfig(config, discoveryCacheDir, httpCacheDir, 10*time.Minute)
   319  }
   320  
   321  // overlyCautiousIllegalFileCharacters matches characters that *might* not be supported.  Windows is really restrictive, so this is really restrictive
   322  var overlyCautiousIllegalFileCharacters = regexp.MustCompile(`[^(\w/.)]`)
   323  
   324  // computeDiscoverCacheDir takes the parentDir and the host and comes up with a "usually non-colliding" name.
   325  func computeDiscoverCacheDir(parentDir, host string) string {
   326  	// strip the optional scheme from host if its there:
   327  	schemelessHost := strings.Replace(strings.Replace(host, "https://", "", 1), "http://", "", 1)
   328  	// now do a simple collapse of non-AZ09 characters.  Collisions are possible but unlikely.  Even if we do collide the problem is short lived
   329  	safeHost := overlyCautiousIllegalFileCharacters.ReplaceAllString(schemelessHost, "_")
   330  	return filepath.Join(parentDir, safeHost)
   331  }