Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 156 additions & 24 deletions internal/k8s/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,66 +159,129 @@ func (c *Client) GetClusterInfo() (models.ClusterInfo, error) {
return models.ClusterInfo{}, fmt.Errorf("failed to get server version: %w", err)
}

crdList, err := c.ExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().List(context.Background(), metav1.ListOptions{})
// Count all API resources using Discovery API
apiResourceLists, err := c.DiscoveryClient.ServerPreferredResources()
if err != nil {
return models.ClusterInfo{}, fmt.Errorf("failed to fetch CRDs: %w", err)
c.log.Warn("could not discover all server resources", "err", err)
}

resourceCount := 0
for _, list := range apiResourceLists {
for _, resource := range list.APIResources {
// Skip subresources
if !strings.Contains(resource.Name, "/") {
resourceCount++
}
}
}

return models.ClusterInfo{
ClusterName: c.ClusterName,
ServerVersion: versionInfo.GitVersion,
NumCRDs: len(crdList.Items),
NumCRDs: resourceCount, // Kept for backward compatibility
NumResources: resourceCount,
}, nil
}

func (c *Client) GetCRDs(ctx context.Context) ([]models.CRD, error) {
crdList, err := c.ExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{})
// Use Discovery API to get all API resources in the cluster
apiResourceLists, err := c.DiscoveryClient.ServerPreferredResources()
if err != nil {
return nil, fmt.Errorf("failed to fetch CRDs: %w", err)
// ServerPreferredResources can return partial results even on error
// This is often caused by aggregated API servers being unavailable
c.log.Warn("could not discover all server resources, using partial results", "err", err)
}

if apiResourceLists == nil {
return nil, fmt.Errorf("failed to discover any API resources")
}

// Collect all resources that can be listed
var resources []struct {
gv schema.GroupVersion
resource metav1.APIResource
}

for _, list := range apiResourceLists {
gv, err := schema.ParseGroupVersion(list.GroupVersion)
if err != nil {
c.log.Warn("could not parse group version", "groupVersion", list.GroupVersion, "err", err)
continue
}

for _, resource := range list.APIResources {
// Skip subresources (e.g., pods/log, deployments/status)
if strings.Contains(resource.Name, "/") {
continue
}

// Skip resources that can't be listed
if !contains(resource.Verbs, "list") {
continue
}

// Skip componentstatuses (deprecated and not useful)
if gv.Group == "" && resource.Name == "componentstatuses" {
continue
}

resources = append(resources, struct {
gv schema.GroupVersion
resource metav1.APIResource
}{gv: gv, resource: resource})
}
}
uiCrds := make([]models.CRD, len(crdList.Items))

// Create CRD models with instance counts
uiCrds := make([]models.CRD, len(resources))
var g errgroup.Group
for i, crd := range crdList.Items {
i, crd := i, crd
g.SetLimit(20) // Limit concurrent goroutines

for i, res := range resources {
i, res := i, res
g.Go(func() error {
instanceCount := c.CountCRDInstances(ctx, crd)
uiCrds[i] = models.FromK8sCRD(crd, instanceCount)
gvr := schema.GroupVersionResource{
Group: res.gv.Group,
Version: res.gv.Version,
Resource: res.resource.Name,
}
instanceCount := c.countResourceInstances(ctx, gvr)
uiCrds[i] = models.FromAPIResource(res.gv, res.resource, instanceCount)
return nil
})
}

if err := g.Wait(); err != nil {
return nil, err
}

return uiCrds, nil
}

func (c *Client) GetCRsForCRD(ctx context.Context, crdName string) ([]unstructured.Unstructured, error) {
crd, err := c.ExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crdName, metav1.GetOptions{})
// crdName is now the resource name (plural), e.g., "pods", "deployments", "certificates"
// We need to find the GVR for this resource using Discovery API
gvr, err := c.findGVRForResource(ctx, crdName)
if err != nil {
return nil, fmt.Errorf("failed to get CRD %s: %w", crdName, err)
}
gvr, _ := getGVRFromCRD(*crd)
if gvr.Resource == "" {
return nil, fmt.Errorf("could not determine GVR for CRD %s", crdName)
return nil, fmt.Errorf("failed to find resource %s: %w", crdName, err)
}

list, err := c.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
return nil, fmt.Errorf("failed to list instances for CRD %s: %w", crdName, err)
return nil, fmt.Errorf("failed to list instances for resource %s: %w", crdName, err)
}
return list.Items, nil
}

func (c *Client) GetSingleCR(ctx context.Context, crdName, namespace, name string) (*unstructured.Unstructured, error) {
crd, err := c.ExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().Get(ctx, crdName, metav1.GetOptions{})
// crdName is now the resource name (plural)
gvr, namespaced, err := c.findGVRAndScopeForResource(ctx, crdName)
if err != nil {
return nil, fmt.Errorf("failed to get CRD %s: %w", crdName, err)
}
gvr, _ := getGVRFromCRD(*crd)
if gvr.Resource == "" {
return nil, fmt.Errorf("could not determine GVR for CRD %s", crdName)
return nil, fmt.Errorf("failed to find resource %s: %w", crdName, err)
}

var resource dynamic.ResourceInterface
if crd.Spec.Scope == apiextensionsv1.NamespaceScoped {
if namespaced {
resource = c.DynamicClient.Resource(gvr).Namespace(namespace)
} else {
resource = c.DynamicClient.Resource(gvr)
Expand Down Expand Up @@ -298,13 +361,82 @@ func (c *Client) CountCRDInstances(ctx context.Context, crd apiextensionsv1.Cust
if gvr.Resource == "" {
return 0
}
return c.countResourceInstances(ctx, gvr)
}

func (c *Client) countResourceInstances(ctx context.Context, gvr schema.GroupVersionResource) int {
list, err := c.DynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{TimeoutSeconds: &[]int64{5}[0]})
if err != nil {
return 0
}
return len(list.Items)
}

func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}

// findGVRForResource finds the GroupVersionResource for a given resource name using Discovery API.
// It returns the preferred version of the resource.
func (c *Client) findGVRForResource(ctx context.Context, resourceName string) (schema.GroupVersionResource, error) {
gvr, _, err := c.findGVRAndScopeForResource(ctx, resourceName)
return gvr, err
}

// findGVRAndScopeForResource finds the GroupVersionResource and scope for a given resource name.
func (c *Client) findGVRAndScopeForResource(ctx context.Context, resourceName string) (schema.GroupVersionResource, bool, error) {
apiResourceLists, err := c.DiscoveryClient.ServerPreferredResources()
if err != nil {
c.log.Warn("could not discover all server resources", "err", err)
}

if apiResourceLists == nil {
return schema.GroupVersionResource{}, false, fmt.Errorf("no API resources discovered")
}

// Search for the resource in all API groups
// Prefer the most recent version if multiple exist
var foundGVR schema.GroupVersionResource
var foundNamespaced bool
var found bool

for _, list := range apiResourceLists {
gv, err := schema.ParseGroupVersion(list.GroupVersion)
if err != nil {
continue
}

for _, resource := range list.APIResources {
if resource.Name == resourceName {
foundGVR = schema.GroupVersionResource{
Group: gv.Group,
Version: gv.Version,
Resource: resource.Name,
}
foundNamespaced = resource.Namespaced
found = true
// We found a match; use the first one we encounter
// (ServerPreferredResources returns preferred versions first)
break
}
}
if found {
break
}
}

if !found {
return schema.GroupVersionResource{}, false, fmt.Errorf("resource %s not found in cluster", resourceName)
}

return foundGVR, foundNamespaced, nil
}

func getGVRFromCRD(crd apiextensionsv1.CustomResourceDefinition) (schema.GroupVersionResource, string) {
storageVersion := ""
for _, v := range crd.Spec.Versions {
Expand Down
65 changes: 62 additions & 3 deletions internal/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ package models
import (
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)

// APICRD is used for the web API, to include the full spec.
Expand All @@ -31,23 +32,80 @@ type APICRD struct {
}

// CRD model is used for the TUI, which only needs a subset of fields.
// Despite the name, this can represent any Kubernetes resource (both CRD-based and built-in).
type CRD struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Name string `json:"name"`
Name string `json:"name"` // For CRDs: the CRD name. For others: plural resource name
Group string `json:"group"`
Scope string `json:"scope"`
Version string `json:"version"` // API version
Resource string `json:"resource"` // Plural resource name (e.g., "pods", "deployments")
Scope string `json:"scope"` // "Namespaced" or "Cluster"
InstanceCount int `json:"instanceCount"`
Namespaced bool `json:"namespaced"` // Whether resource is namespaced
}

func FromK8sCRD(k8sCrd apiextensionsv1.CustomResourceDefinition, instanceCount int) CRD {
// Determine the storage version
version := ""
for _, v := range k8sCrd.Spec.Versions {
if v.Storage {
version = v.Name
break
}
}
if version == "" && len(k8sCrd.Spec.Versions) > 0 {
version = k8sCrd.Spec.Versions[0].Name
}

namespaced := k8sCrd.Spec.Scope == apiextensionsv1.NamespaceScoped

return CRD{
APIVersion: k8sCrd.APIVersion,
Kind: k8sCrd.Spec.Names.Kind,
Name: k8sCrd.Name,
Group: k8sCrd.Spec.Group,
Version: version,
Resource: k8sCrd.Spec.Names.Plural,
Scope: string(k8sCrd.Spec.Scope),
InstanceCount: instanceCount,
Namespaced: namespaced,
}
}

// FromAPIResource creates a CRD model from a discovered API resource.
// This enables the tool to work with all Kubernetes resources, not just CRDs.
func FromAPIResource(gv schema.GroupVersion, resource metav1.APIResource, instanceCount int) CRD {
scope := "Cluster"
if resource.Namespaced {
scope = "Namespaced"
}

// For built-in resources (empty group), use the version as part of APIVersion
apiVersion := gv.String()
if gv.Group == "" {
apiVersion = gv.Version
}

return CRD{
APIVersion: apiVersion,
Kind: resource.Kind,
Name: resource.Name, // Use plural name as identifier
Group: gv.Group,
Version: gv.Version,
Resource: resource.Name,
Scope: scope,
InstanceCount: instanceCount,
Namespaced: resource.Namespaced,
}
}

// GVR returns the GroupVersionResource for this resource.
func (c CRD) GVR() schema.GroupVersionResource {
return schema.GroupVersionResource{
Group: c.Group,
Version: c.Version,
Resource: c.Resource,
}
}

Expand Down Expand Up @@ -86,5 +144,6 @@ type Edge struct {
type ClusterInfo struct {
ClusterName string `json:"clusterName"`
ServerVersion string `json:"serverVersion"`
NumCRDs int `json:"numCRDs"`
NumCRDs int `json:"numCRDs"` // For backward compatibility, renamed to NumResources internally
NumResources int `json:"numResources"`
}
30 changes: 5 additions & 25 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,10 @@ import (
"encoding/json"
"io/fs"
"net/http"
"sync"
"time"

apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/pehlicd/crd-wizard/internal/logger"
"github.com/pehlicd/crd-wizard/internal/models"

"github.com/pehlicd/crd-wizard/internal/k8s"
"github.com/pehlicd/crd-wizard/internal/logger"
)

//go:embed static/*
Expand Down Expand Up @@ -127,29 +121,15 @@ func (s *Server) ClusterInfoHandler(w http.ResponseWriter, _ *http.Request) {
}

func (s *Server) CrdsHandler(w http.ResponseWriter, _ *http.Request) {
// Note: This re-uses the k8s.GetCRDs which returns the TUI model.
// For the API, we want the full spec, so we fetch the raw list and convert.
crdList, err := s.K8sClient.ExtensionsClient.ApiextensionsV1().CustomResourceDefinitions().List(context.Background(), metav1.ListOptions{})
// Use Discovery API to list all resources (works for all users, including unprivileged)
crds, err := s.K8sClient.GetCRDs(context.Background())
if err != nil {
s.log.Error("error listing CRDs", "err", err)
s.log.Error("error listing resources", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}

apiCrds := make([]models.APICRD, len(crdList.Items))
var wg sync.WaitGroup
for i, crd := range crdList.Items {
wg.Add(1)
go func(i int, crd apiextensionsv1.CustomResourceDefinition) {
defer wg.Done()
// This is a bit inefficient as it recounts, but for correctness with the new model.
instanceCount := s.K8sClient.CountCRDInstances(context.Background(), crd)
apiCrds[i] = models.ToAPICRD(crd, instanceCount)
}(i, crd)
}
wg.Wait()

s.respondWithJSON(w, http.StatusOK, apiCrds)
s.respondWithJSON(w, http.StatusOK, crds)
}

func (s *Server) CrsHandler(w http.ResponseWriter, r *http.Request) {
Expand Down