Skip to content

feat: Nutanix VM image preflight check #1130

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: dlipovetsky/preflight-checks-framework
Choose a base branch
from
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
2 changes: 2 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/handlers/options"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/cluster"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
preflightnutanix "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight/nutanix"
)

func main() {
Expand Down Expand Up @@ -224,6 +225,7 @@ func main() {
Handler: preflight.New(mgr.GetClient(), admission.NewDecoder(mgr.GetScheme()),
[]preflight.Checker{
// Add your preflight checkers here.
&preflightnutanix.Checker{},
}...,
),
})
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/nutanix/ntnx-api-golang-clients/clustermgmt-go-client/v4 v4.0.1-beta.2
github.com/nutanix/ntnx-api-golang-clients/networking-go-client/v4 v4.0.2-beta.1
github.com/nutanix/ntnx-api-golang-clients/prism-go-client/v4 v4.0.1-beta.1
github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1
github.com/onsi/ginkgo/v2 v2.23.4
github.com/onsi/gomega v1.37.0
github.com/pkg/errors v0.9.1
Expand Down Expand Up @@ -111,7 +112,6 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nutanix/ntnx-api-golang-clients/storage-go-client/v4 v4.0.2-alpha.3 // indirect
github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4 v4.0.1-beta.1 // indirect
github.com/nutanix/ntnx-api-golang-clients/volumes-go-client/v4 v4.0.1-beta.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0-rc5 // indirect
Expand Down
79 changes: 79 additions & 0 deletions pkg/webhook/preflight/nutanix/checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// Copyright 2025 Nutanix. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package nutanix

import (
"context"
"fmt"

clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"

prismv4 "github.com/nutanix-cloud-native/prism-go-client/v4"

"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
preflightutil "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight/util"
)

type Checker struct {
client ctrlclient.Client
cluster *clusterv1.Cluster

nutanixClient *prismv4.Client
variablesGetter *preflightutil.VariablesGetter
}

func (n *Checker) Init(
ctx context.Context,
client ctrlclient.Client,
cluster *clusterv1.Cluster,
) []preflight.Check {
n.client = client
n.cluster = cluster
n.variablesGetter = preflightutil.NewVariablesGetter(cluster)

// Initialize the Nutanix client. If it fails, return a check that indicates the error.
clusterConfig, err := n.variablesGetter.ClusterConfig()
if err != nil {
return []preflight.Check{
func(ctx context.Context) preflight.CheckResult {
return preflight.CheckResult{
Name: "NutanixClientInitialization",
Allowed: false,
Error: true,
Causes: []preflight.Cause{
{
Message: fmt.Sprintf("failed to read clusterConfig variable: %s", err),
Field: "cluster.spec.topology.variables",
},
},
}
},
}
}

n.nutanixClient, err = v4client(ctx, client, cluster, clusterConfig.Nutanix)
// TODO Verify the credentials by making a users API call.
if err != nil {
return []preflight.Check{
func(ctx context.Context) preflight.CheckResult {
return preflight.CheckResult{
Name: "NutanixClientInitialization",
Allowed: false,
Error: true,
Causes: []preflight.Cause{
{
Message: fmt.Sprintf("failed to initialize Nutanix client: %s", err),
Field: "cluster.spec.topology.variables[.name=clusterConfig].nutanix",
},
},
}
},
}
}

return []preflight.Check{
n.VMImages,
}
}
69 changes: 69 additions & 0 deletions pkg/webhook/preflight/nutanix/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package nutanix

import (
"context"
"fmt"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
ctrlclient "sigs.k8s.io/controller-runtime/pkg/client"

prism "github.com/nutanix-cloud-native/prism-go-client"
prismcredentials "github.com/nutanix-cloud-native/prism-go-client/environment/credentials"
prismv4 "github.com/nutanix-cloud-native/prism-go-client/v4"

carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
)

func v4client(ctx context.Context,
client ctrlclient.Client,
cluster *clusterv1.Cluster,
nutanixSpec *carenv1.NutanixSpec,
) (
*prismv4.Client,
error,
) {
if nutanixSpec == nil {
return nil, fmt.Errorf("nutanixSpec is nil")
}

if nutanixSpec.PrismCentralEndpoint.Credentials.SecretRef.Name == "" {
return nil, fmt.Errorf("prism Central credentials reference SecretRef.Name has no value")
}

credentialsSecret := &corev1.Secret{}
if err := client.Get(
ctx,
types.NamespacedName{
Namespace: cluster.Namespace,
Name: nutanixSpec.PrismCentralEndpoint.Credentials.SecretRef.Name,
},
credentialsSecret,
); err != nil {
return nil, fmt.Errorf("failed to get Prism Central credentials Secret: %w", err)
}

// Get username and password
credentials, err := prismcredentials.ParseCredentials(credentialsSecret.Data["credentials"])
if err != nil {
return nil, fmt.Errorf("failed to parse Prism Central credentials from Secret: %w", err)
}

host, port, err := nutanixSpec.PrismCentralEndpoint.ParseURL()
if err != nil {
return nil, fmt.Errorf("failed to parse Prism Central endpoint: %w", err)
}

nutanixClient, err := prismv4.NewV4Client(prism.Credentials{
Endpoint: fmt.Sprintf("%s:%d", host, port),
Username: credentials.Username,
Password: credentials.Password,
Insecure: nutanixSpec.PrismCentralEndpoint.Insecure,
})
if err != nil {
return nil, fmt.Errorf("failed to create Prism V4 client: %w", err)
}

return nutanixClient, nil
}
143 changes: 143 additions & 0 deletions pkg/webhook/preflight/nutanix/image.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package nutanix

import (
"context"
"fmt"

vmmv4 "github.com/nutanix/ntnx-api-golang-clients/vmm-go-client/v4/models/vmm/v4/content"

prismv4 "github.com/nutanix-cloud-native/prism-go-client/v4"

capxv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/external/github.com/nutanix-cloud-native/cluster-api-provider-nutanix/api/v1beta1"
carenv1 "github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/v1alpha1"
"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/pkg/webhook/preflight"
)

func (n *Checker) VMImages(ctx context.Context) preflight.CheckResult {
result := preflight.CheckResult{
Name: "VMImages",
Allowed: true,
}

// Check control plane VM image.
clusterConfig, err := n.variablesGetter.ClusterConfig()
if err != nil {
result.Error = true
result.Allowed = false
result.Causes = append(result.Causes, preflight.Cause{
Message: fmt.Sprintf("failed to read clusterConfig variable: %s", err),
Field: "cluster.spec.topology.variables",
})
}
if clusterConfig != nil && clusterConfig.ControlPlane != nil && clusterConfig.ControlPlane.Nutanix != nil {
n.vmImageCheckForMachineDetails(
ctx,
&clusterConfig.ControlPlane.Nutanix.MachineDetails,
"cluster.spec.topology.variables[.name=clusterConfig].controlPlane.nutanix.machineDetails",
&result,
)
}

// Check worker VM images.
if n.cluster.Spec.Topology.Workers != nil {
for _, md := range n.cluster.Spec.Topology.Workers.MachineDeployments {
workerConfig, err := n.variablesGetter.WorkerConfigForMachineDeployment(md)
if err != nil {
result.Error = true
result.Causes = append(result.Causes, preflight.Cause{
Message: fmt.Sprintf("failed to read workerConfig variable: %s", err),
Field: fmt.Sprintf(
"cluster.spec.topology.workers.machineDeployments[.name=%s].variables.overrides",
md.Name,
),
})
}
if workerConfig != nil && workerConfig.Nutanix != nil {
n.vmImageCheckForMachineDetails(
ctx,
&workerConfig.Nutanix.MachineDetails,
fmt.Sprintf(
"workers.machineDeployments[.name=%s].variables.overrides[.name=workerConfig].value.nutanix.machineDetails",
md.Name,
),
&result,
)
}
}
}

return result
}

func (n *Checker) vmImageCheckForMachineDetails(
ctx context.Context,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
(*Checker).vmImageCheckForMachineDetails - ctx is unused (unparam)

details *carenv1.NutanixMachineDetails,
field string,
result *preflight.CheckResult,
) {
if details.ImageLookup != nil {
result.Allowed = false
result.Error = true
result.Causes = append(result.Causes, preflight.Cause{
Message: "ImageLookup is not yet supported",
Field: field,
})
return
}

if details.Image != nil {
images, err := getVMImages(n.nutanixClient, details.Image)
if err != nil {
result.Allowed = false
result.Error = true
result.Causes = append(result.Causes, preflight.Cause{
Message: fmt.Sprintf("failed to count matching VM Images: %s", err),
Field: field,
})
return
}

if len(images) != 1 {
result.Allowed = false
result.Causes = append(result.Causes, preflight.Cause{
Message: fmt.Sprintf("expected to find 1 VM Image, found %d", len(images)),
Field: field,
})
}
}
}

func getVMImages(
client *prismv4.Client,
id *capxv1.NutanixResourceIdentifier,
) ([]vmmv4.Image, error) {
switch {
case id.IsUUID():
resp, err := client.ImagesApiInstance.GetImageById(id.UUID)
if err != nil {
return nil, err
}
image, ok := resp.GetData().(vmmv4.Image)
if !ok {
return nil, fmt.Errorf("failed to get data returned by GetImageById")
}
return []vmmv4.Image{image}, nil
case id.IsName():
filter_ := fmt.Sprintf("name eq '%s'", *id.Name)
resp, err := client.ImagesApiInstance.ListImages(nil, nil, &filter_, nil, nil)
if err != nil {
return nil, err
}
if resp == nil || resp.GetData() == nil {
// No images were returned.
return []vmmv4.Image{}, nil
}
images, ok := resp.GetData().([]vmmv4.Image)
if !ok {
return nil, fmt.Errorf("failed to get data returned by ListImages")
}
return images, nil
default:
return nil, fmt.Errorf("image identifier is missing both name and uuid")
}
}
71 changes: 71 additions & 0 deletions pkg/webhook/preflight/util/variables.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package nutanix

import (
"fmt"
"sync"

clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"

"github.com/nutanix-cloud-native/cluster-api-runtime-extensions-nutanix/api/variables"
)

// VariablesGetter provides methods to retrieve variables from a Cluster object.
// These methods are thread-safe and cache the results for efficiency.
type VariablesGetter struct {
cluster *clusterv1.Cluster
workerConfigGetterByMachineDeploymentName map[string]func() (*variables.WorkerNodeConfigSpec, error)
workerConfigGetterByMachineDeploymentNameMutex sync.Mutex
}

func NewVariablesGetter(cluster *clusterv1.Cluster) *VariablesGetter {
return &VariablesGetter{
cluster: cluster,
workerConfigGetterByMachineDeploymentName: make(
map[string]func() (*variables.WorkerNodeConfigSpec, error),
),
workerConfigGetterByMachineDeploymentNameMutex: sync.Mutex{},
}
}

// ClusterConfig retrieves the cluster configuration variables from the Cluster object.
// This method is thread-safe, and caches the result.
func (g *VariablesGetter) ClusterConfig() (*variables.ClusterConfigSpec, error) {
return sync.OnceValues(func() (*variables.ClusterConfigSpec, error) {
if g.cluster.Spec.Topology.Variables == nil {
return nil, nil
}

clusterConfig, err := variables.UnmarshalClusterConfigVariable(g.cluster.Spec.Topology.Variables)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal .variables[.name=clusterConfig]: %w", err)
}

return clusterConfig, nil
})()
}

// WorkerConfigForMachineDeployment retrieves the worker configuration variables for the given MachineDeployment.
// This method is thread-safe, and caches the result.
func (g *VariablesGetter) WorkerConfigForMachineDeployment(
md clusterv1.MachineDeploymentTopology,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚫 [golangci] reported by reviewdog 🐶
hugeParam: md is heavy (120 bytes); consider passing it by pointer (gocritic)

) (*variables.WorkerNodeConfigSpec, error) {
g.workerConfigGetterByMachineDeploymentNameMutex.Lock()
defer g.workerConfigGetterByMachineDeploymentNameMutex.Unlock()

fn, ok := g.workerConfigGetterByMachineDeploymentName[md.Name]
if !ok {
fn = sync.OnceValues(func() (*variables.WorkerNodeConfigSpec, error) {
if md.Variables == nil {
return nil, nil
}

workerConfig, err := variables.UnmarshalWorkerConfigVariable(md.Variables.Overrides)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal .variables.overrides[.name=workerConfig]: %w", err)
}

return workerConfig, nil
})
}
return fn()
}
Loading