// Copyright (c) 2025 Sidero Labs, Inc. // // Use of this software is governed by the Business Source License // included in the LICENSE file. package kubernetes import ( "context" "errors" "fmt" "net" "strings" "time" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/util/connrotation" "sigs.k8s.io/controller-runtime/pkg/client/apiutil" ) // Client wrapper. type Client struct { client dynamic.Interface clientset *kubernetes.Clientset Mapper meta.RESTMapper dialer *connrotation.Dialer } // Resource ... func (c *Client) Resource(res *unstructured.Unstructured) (dynamic.ResourceInterface, error) { //nolint:ireturn var versions []string gvk := res.GroupVersionKind() if gvk.Version != "" { versions = append(versions, gvk.Version) } mapping, err := c.Mapper.RESTMapping(gvk.GroupKind(), versions...) if err != nil { return nil, err } var dr dynamic.ResourceInterface if mapping.Scope.Name() == meta.RESTScopeNameNamespace { dr = c.client.Resource(mapping.Resource).Namespace(res.GetNamespace()) } else { dr = c.client.Resource(mapping.Resource) } return dr, nil } // Create saves the object obj in the Kubernetes cluster. func (c *Client) Create(ctx context.Context, res *unstructured.Unstructured, opts metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { dr, err := c.Resource(res) if err != nil { return nil, err } return dr.Create(ctx, res, opts, subresources...) } // Delete deletes the given obj from Kubernetes cluster. func (c *Client) Delete(ctx context.Context, resource, name, namespace string, opts metav1.DeleteOptions, subresources ...string) error { res, err := c.parseResource(resource, namespace) if err != nil { return err } dr, err := c.Resource(res) if err != nil { return err } return dr.Delete(ctx, name, opts, subresources...) } // Get retrieves an obj for the given object key from the Kubernetes Cluster. func (c *Client) Get(ctx context.Context, resource, name, namespace string, opts metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { res, err := c.parseResource(resource, namespace) if err != nil { return nil, err } dr, err := c.Resource(res) if err != nil { return nil, err } return dr.Get(ctx, name, opts, subresources...) } // List retrieves the list of object for the given object type from the Kubernetes Cluster. func (c *Client) List(ctx context.Context, resource, namespace string, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { res, err := c.parseResource(resource, namespace) if err != nil { return nil, err } dr, err := c.Resource(res) if err != nil { return nil, err } return dr.List(ctx, opts) } // Update updates the resource. func (c *Client) Update(ctx context.Context, res *unstructured.Unstructured, opts metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { dr, err := c.Resource(res) if err != nil { return nil, err } return dr.Update(ctx, res, opts, subresources...) } // Dynamic returns the underlying dynamic client. func (c *Client) Dynamic() dynamic.Interface { //nolint:ireturn return c.client } // Clientset returns the underlying clientset. func (c *Client) Clientset() *kubernetes.Clientset { return c.clientset } // Close closes all clients. func (c *Client) Close() { c.dialer.CloseAll() } func (c *Client) kindFor(gvr schema.GroupVersionResource) (schema.GroupVersionKind, error) { return c.Mapper.KindFor(gvr) } func (c *Client) parseResource(resource, namespace string) (*unstructured.Unstructured, error) { gvr, err := c.getGVR(resource) if err != nil { return nil, err } res := &unstructured.Unstructured{} gvk, err := c.kindFor(*gvr) if err != nil { return nil, err } res.SetGroupVersionKind(gvk) res.SetNamespace(namespace) return res, nil } func (c *Client) getGVR(resource string) (*schema.GroupVersionResource, error) { var gvr *schema.GroupVersionResource parts := strings.Split(resource, ".") var err error switch { case len(parts) == 2: gvr = &schema.GroupVersionResource{ Resource: parts[0], Version: parts[1], } case len(parts) == 1: gvr, err = c.discoverGVR(resource) if err != nil { return nil, err } default: gvr, _ = schema.ParseResourceArg(resource) } if gvr == nil { return nil, errors.New("couldn't parse resource name") } return gvr, nil } func (c *Client) discoverGVR(resource string) (*schema.GroupVersionResource, error) { gvr := &schema.GroupVersionResource{ Resource: resource, } resources, err := c.clientset.ServerPreferredResources() if err != nil { return nil, err } for _, res := range resources { for _, r := range res.APIResources { if r.Name == resource { gv, err := schema.ParseGroupVersion(res.GroupVersion) if err != nil { return nil, err } gvr.Version = gv.Version gvr.Group = gv.Group } } } return gvr, nil } // NewClient creates new Kubernetes client. func NewClient(config *rest.Config) (*Client, error) { dialerRef := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second} dialer := connrotation.NewDialer(dialerRef.DialContext) config.Dial = dialer.DialContext client, err := rest.HTTPClientFor(config) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for %q: %w", config.Host, err) } mapper, err := apiutil.NewDynamicRESTMapper(config, client) if err != nil { return nil, err } if config.Timeout == 0 { config.Timeout = 30 * time.Second } c, err := dynamic.NewForConfig(config) if err != nil { return nil, err } var clientset *kubernetes.Clientset clientset, err = kubernetes.NewForConfig(config) if err != nil { return nil, err } return &Client{c, clientset, mapper, dialer}, nil }