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 }