diff --git a/pkg/webhook/preflight/nutanix/cache.go b/pkg/webhook/preflight/nutanix/cache.go new file mode 100644 index 000000000..0c70e30f3 --- /dev/null +++ b/pkg/webhook/preflight/nutanix/cache.go @@ -0,0 +1,32 @@ +package nutanix + +import ( + v4Converged "github.com/nutanix-cloud-native/prism-go-client/converged/v4" + "github.com/nutanix-cloud-native/prism-go-client/environment/types" + v3 "github.com/nutanix-cloud-native/prism-go-client/v3" +) + +// NutanixClientCache is the cache of prism clients to be shared across the different controllers. +// +//nolint:gochecknoglobals // Client cache must be a package-level singleton for connection pooling +var NutanixClientCache = v3.NewClientCache(v3.WithSessionAuth(true)) + +// NutanixConvergedClientV4Cache is the cache of prism clients to be shared across the different controllers. +// +//nolint:gochecknoglobals // Client cache must be a package-level singleton for connection pooling +var NutanixConvergedClientV4Cache = v4Converged.NewClientCache() + +// CacheParams is the struct that implements ClientCacheParams interface from prism-go-client. +type CacheParams struct { + PrismManagementEndpoint *types.ManagementEndpoint +} + +// ManagementEndpoint returns the management endpoint of the NutanixCluster CR. +func (c *CacheParams) ManagementEndpoint() types.ManagementEndpoint { + return *c.PrismManagementEndpoint +} + +// Key returns a unique key for the client cache based on the management endpoint. +func (c *CacheParams) Key() string { + return c.PrismManagementEndpoint.Address.String() +} diff --git a/pkg/webhook/preflight/nutanix/clients.go b/pkg/webhook/preflight/nutanix/clients.go index 4ba149276..2ca11edf9 100644 --- a/pkg/webhook/preflight/nutanix/clients.go +++ b/pkg/webhook/preflight/nutanix/clients.go @@ -5,25 +5,27 @@ package nutanix import ( "context" + "errors" "fmt" + "net/url" + "strings" clustermgmtv4 "github.com/nutanix/ntnx-api-golang-clients/clustermgmt-go-client/v4/models/clustermgmt/v4/config" netv4 "github.com/nutanix/ntnx-api-golang-clients/networking-go-client/v4/models/networking/v4/config" vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content" prismgoclient "github.com/nutanix-cloud-native/prism-go-client" - prismv3 "github.com/nutanix-cloud-native/prism-go-client/v3" - prismv4 "github.com/nutanix-cloud-native/prism-go-client/v4" + "github.com/nutanix-cloud-native/prism-go-client/converged" + "github.com/nutanix-cloud-native/prism-go-client/environment/types" ) -// client contains methods to interact with Nutanix Prism v3 and v4 APIs. +// client contains methods to interact with Nutanix Prism converged v4 client. type client interface { - GetCurrentLoggedInUser( + // ValidateCredentials validates credentials by making a lightweight API call. + // This replaces the V3 GetCurrentLoggedInUser() for credential validation. + ValidateCredentials( ctx context.Context, - ) ( - *prismv3.UserIntentResponse, - error, - ) + ) error GetPrismCentralVersion( ctx context.Context, @@ -110,13 +112,11 @@ type client interface { ) } -// clientWrapper implements the client interface and wraps both v3 and v4 clients. +// clientWrapper implements the client interface and wraps converged v4 client. type clientWrapper struct { - GetCurrentLoggedInUserFunc func( + ValidateCredentialsFunc func( ctx context.Context, - ) ( - *prismv3.UserIntentResponse, error, - ) + ) error GetPrismCentralVersionFunc func( ctx context.Context, @@ -196,60 +196,224 @@ type clientWrapper struct { var _ = client(&clientWrapper{}) +// ErrEmptyHostInURL is returned when the parsed URL has an empty host. +var ErrEmptyHostInURL = errors.New("parsed URL has empty host") + +// buildODataOptions converts pointer-based OData parameters to functional options. +func buildODataOptions(page, limit *int, filter, orderby, selectFields *string) []converged.ODataOption { + var opts []converged.ODataOption + if page != nil { + opts = append(opts, converged.WithPage(*page)) + } + if limit != nil { + opts = append(opts, converged.WithLimit(*limit)) + } + if filter != nil && *filter != "" { + opts = append(opts, converged.WithFilter(*filter)) + } + if orderby != nil && *orderby != "" { + opts = append(opts, converged.WithOrderBy(*orderby)) + } + if selectFields != nil && *selectFields != "" { + opts = append(opts, converged.WithSelect(*selectFields)) + } + return opts +} + +// buildManagementEndpoint creates a ManagementEndpoint from credentials and trust bundle. +func buildManagementEndpoint(credentials *prismgoclient.Credentials) (*types.ManagementEndpoint, error) { + urlStr := credentials.URL + + // Prepend https:// if no scheme is present + // Nutanix Prism Central URLs may be provided as "host:port" without scheme + if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { + urlStr = "https://" + urlStr + } + + // Parse URL - preserve existing scheme if present (e.g., for test servers) + parsedURL, err := url.Parse(urlStr) + if err != nil { + return nil, fmt.Errorf("failed to parse URL %q: %w", urlStr, err) + } + + // Validate that we have a host after parsing + if parsedURL.Host == "" { + return nil, fmt.Errorf("invalid URL %q: %w", credentials.URL, ErrEmptyHostInURL) + } + + return &types.ManagementEndpoint{ + Address: parsedURL, + Insecure: credentials.Insecure, + ApiCredentials: types.ApiCredentials{ + Username: credentials.Username, + Password: credentials.Password, + }, + }, nil +} + func newClient( credentials prismgoclient.Credentials, //nolint:gocritic // hugeParam is fine ) (client, error) { - v3c, err := prismv3.NewV3Client(credentials) + endpoint, err := buildManagementEndpoint(&credentials) if err != nil { - return nil, fmt.Errorf("failed to create v3 client: %w", err) + return nil, fmt.Errorf("failed to build management endpoint: %w", err) + } + cacheParams := &CacheParams{ + PrismManagementEndpoint: endpoint, } - v4c, err := prismv4.NewV4Client(credentials) + convergedc, err := NutanixConvergedClientV4Cache.GetOrCreate(cacheParams) if err != nil { - return nil, fmt.Errorf("failed to create v4 client: %w", err) + return nil, fmt.Errorf("failed to create converged client: %w", err) } return &clientWrapper{ - GetCurrentLoggedInUserFunc: v3c.V3.GetCurrentLoggedInUser, + ValidateCredentialsFunc: func(ctx context.Context) error { + // Use Users.List() as a lightweight API call to validate credentials. + // This is available to all users and serves the same purpose as V3's GetCurrentLoggedInUser. + _, err := convergedc.Users.List(ctx, converged.WithLimit(1)) + return err + }, GetPrismCentralVersionFunc: func(ctx context.Context) (string, error) { - pcInfo, err := v3c.V3.GetPrismCentral(ctx) + // Use DomainManager.GetPrismCentralVersion() as V4 equivalent to V3's GetPrismCentral(). + return convergedc.DomainManager.GetPrismCentralVersion(ctx) + }, + GetImageByIdFunc: func(uuid *string, args ...map[string]interface{}) (*vmmv4.GetImageApiResponse, error) { + if uuid == nil { + return nil, fmt.Errorf("uuid cannot be nil") + } + image, err := convergedc.Images.Get(context.Background(), *uuid) if err != nil { - return "", err + return nil, err } - - if pcInfo == nil || pcInfo.Resources == nil || pcInfo.Resources.Version == nil { - return "", fmt.Errorf("failed to get Prism Central version: API did not return the PC version") + resp := &vmmv4.GetImageApiResponse{} + resp.Data = vmmv4.NewOneOfGetImageApiResponseData() + if err := resp.Data.SetValue(image); err != nil { + return nil, fmt.Errorf("failed to set image response data: %w", err) } - - return *pcInfo.Resources.Version, nil + return resp, nil + }, + ListImagesFunc: func( + page_ *int, + limit_ *int, + filter_ *string, + orderby_ *string, + select_ *string, + args ...map[string]interface{}, + ) (*vmmv4.ListImagesApiResponse, error) { + opts := buildODataOptions(page_, limit_, filter_, orderby_, select_) + images, err := convergedc.Images.List(context.Background(), opts...) + if err != nil { + return nil, err + } + resp := &vmmv4.ListImagesApiResponse{} + resp.Data = vmmv4.NewOneOfListImagesApiResponseData() + if err := resp.Data.SetValue(images); err != nil { + return nil, fmt.Errorf("failed to set images response data: %w", err) + } + return resp, nil }, - GetImageByIdFunc: v4c.ImagesApiInstance.GetImageById, - ListImagesFunc: v4c.ImagesApiInstance.ListImages, GetClusterByIdFunc: func(uuid *string, args ...map[string]interface{}) (*clustermgmtv4.GetClusterApiResponse, error) { - return v4c.ClustersApiInstance.GetClusterById(uuid, nil, args...) + if uuid == nil { + return nil, fmt.Errorf("uuid cannot be nil") + } + cluster, err := convergedc.Clusters.Get(context.Background(), *uuid) + if err != nil { + return nil, err + } + resp := &clustermgmtv4.GetClusterApiResponse{} + resp.Data = clustermgmtv4.NewOneOfGetClusterApiResponseData() + if err := resp.Data.SetValue(cluster); err != nil { + return nil, fmt.Errorf("failed to set cluster response data: %w", err) + } + return resp, nil }, ListClustersFunc: func( page_, limit_ *int, filter_, orderby_, apply_, select_ *string, args ...map[string]interface{}, ) (*clustermgmtv4.ListClustersApiResponse, error) { - return v4c.ClustersApiInstance.ListClusters( - page_, limit_, filter_, orderby_, apply_, nil, select_, args..., - ) + opts := buildODataOptions(page_, limit_, filter_, orderby_, select_) + if apply_ != nil && *apply_ != "" { + opts = append(opts, converged.WithApply(*apply_)) + } + clusters, err := convergedc.Clusters.List(context.Background(), opts...) + if err != nil { + return nil, err + } + resp := &clustermgmtv4.ListClustersApiResponse{} + resp.Data = clustermgmtv4.NewOneOfListClustersApiResponseData() + if err := resp.Data.SetValue(clusters); err != nil { + return nil, fmt.Errorf("failed to set clusters response data: %w", err) + } + return resp, nil + }, + ListStorageContainersFunc: func( + page_ *int, + limit_ *int, + filter_ *string, + orderby_ *string, + select_ *string, + args ...map[string]interface{}, + ) (*clustermgmtv4.ListStorageContainersApiResponse, error) { + opts := buildODataOptions(page_, limit_, filter_, orderby_, select_) + containers, err := convergedc.StorageContainers.List(context.Background(), opts...) + if err != nil { + return nil, err + } + resp := &clustermgmtv4.ListStorageContainersApiResponse{} + resp.Data = clustermgmtv4.NewOneOfListStorageContainersApiResponseData() + if err := resp.Data.SetValue(containers); err != nil { + return nil, fmt.Errorf("failed to set storage containers response data: %w", err) + } + return resp, nil + }, + GetSubnetByIdFunc: func(uuid *string, args ...map[string]interface{}) (*netv4.GetSubnetApiResponse, error) { + if uuid == nil { + return nil, fmt.Errorf("uuid cannot be nil") + } + subnet, err := convergedc.Subnets.Get(context.Background(), *uuid) + if err != nil { + return nil, err + } + resp := &netv4.GetSubnetApiResponse{} + resp.Data = netv4.NewOneOfGetSubnetApiResponseData() + if err := resp.Data.SetValue(subnet); err != nil { + return nil, fmt.Errorf("failed to set subnet response data: %w", err) + } + return resp, nil + }, + ListSubnetsFunc: func( + page_ *int, + limit_ *int, + filter_ *string, + orderby_ *string, + expand_ *string, + select_ *string, + args ...map[string]interface{}, + ) (*netv4.ListSubnetsApiResponse, error) { + opts := buildODataOptions(page_, limit_, filter_, orderby_, select_) + if expand_ != nil && *expand_ != "" { + opts = append(opts, converged.WithExpand(*expand_)) + } + subnets, err := convergedc.Subnets.List(context.Background(), opts...) + if err != nil { + return nil, err + } + resp := &netv4.ListSubnetsApiResponse{} + resp.Data = netv4.NewOneOfListSubnetsApiResponseData() + if err := resp.Data.SetValue(subnets); err != nil { + return nil, fmt.Errorf("failed to set subnets response data: %w", err) + } + return resp, nil }, - ListStorageContainersFunc: v4c.StorageContainerAPI.ListStorageContainers, - GetSubnetByIdFunc: v4c.SubnetsApiInstance.GetSubnetById, - ListSubnetsFunc: v4c.SubnetsApiInstance.ListSubnets, }, nil } -func (c *clientWrapper) GetCurrentLoggedInUser( +func (c *clientWrapper) ValidateCredentials( ctx context.Context, -) ( - *prismv3.UserIntentResponse, - error, -) { - return c.GetCurrentLoggedInUserFunc(ctx) +) error { + return c.ValidateCredentialsFunc(ctx) } func (c *clientWrapper) GetPrismCentralVersion( diff --git a/pkg/webhook/preflight/nutanix/credentials.go b/pkg/webhook/preflight/nutanix/credentials.go index 240b418a6..9a1ecf768 100644 --- a/pkg/webhook/preflight/nutanix/credentials.go +++ b/pkg/webhook/preflight/nutanix/credentials.go @@ -193,9 +193,9 @@ func newCredentialsCheck( } // Validate the credentials using an API call. - _, err = nclient.GetCurrentLoggedInUser(ctx) + err = nclient.ValidateCredentials(ctx) if err == nil { - // We initialized both clients, and verified the credentials using the v3 client. + // We initialized the converged client and verified the credentials using the Users API. cd.nclient = nclient return credentialsCheck } diff --git a/pkg/webhook/preflight/nutanix/credentials_test.go b/pkg/webhook/preflight/nutanix/credentials_test.go index 93e8e5237..f5e1362f5 100644 --- a/pkg/webhook/preflight/nutanix/credentials_test.go +++ b/pkg/webhook/preflight/nutanix/credentials_test.go @@ -16,18 +16,16 @@ import ( ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" - prismgoclient "github.com/nutanix-cloud-native/prism-go-client" - prismv3 "github.com/nutanix-cloud-native/prism-go-client/v3" - carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1" + prismgoclient "github.com/nutanix-cloud-native/prism-go-client" ) func TestNewCredentialsCheck_Success(t *testing.T) { cd := validCheckDependencies() nclientFactory := func(_ prismgoclient.Credentials) (client, error) { return &clientWrapper{ - GetCurrentLoggedInUserFunc: func(ctx context.Context) (*prismv3.UserIntentResponse, error) { - return &prismv3.UserIntentResponse{}, nil + ValidateCredentialsFunc: func(ctx context.Context) error { + return nil }, }, nil } @@ -219,12 +217,12 @@ func TestNewCredentialsCheck_FailedToCreateClient(t *testing.T) { ) } -func TestNewCredentialsCheck_FailedToGetCurrentLoggedInUser(t *testing.T) { - // Simulate a failure in getting the current logged-in user +func TestNewCredentialsCheck_FailedToValidateCredentials(t *testing.T) { + // Simulate a failure in validating credentials nclientFactory := func(_ prismgoclient.Credentials) (client, error) { return &clientWrapper{ - GetCurrentLoggedInUserFunc: func(ctx context.Context) (*prismv3.UserIntentResponse, error) { - return nil, assert.AnError + ValidateCredentialsFunc: func(ctx context.Context) error { + return assert.AnError }, }, nil } @@ -237,11 +235,11 @@ func TestNewCredentialsCheck_FailedToGetCurrentLoggedInUser(t *testing.T) { assert.AnError.Error()) } -func TestNewCredentialsCheck_GetCurrentLoggedInUserInvalidCredentials(t *testing.T) { +func TestNewCredentialsCheck_ValidateCredentialsInvalidCredentials(t *testing.T) { nclientFactory := func(_ prismgoclient.Credentials) (client, error) { return &clientWrapper{ - GetCurrentLoggedInUserFunc: func(ctx context.Context) (*prismv3.UserIntentResponse, error) { - return nil, fmt.Errorf("invalid Nutanix credentials") + ValidateCredentialsFunc: func(ctx context.Context) error { + return fmt.Errorf("invalid Nutanix credentials") }, }, nil }