From 588efe2ce42c36b7a4ae5d2258519f338413cba0 Mon Sep 17 00:00:00 2001 From: Pradeep Kumar Date: Tue, 7 Apr 2026 21:18:06 +0530 Subject: [PATCH] NSOL-6260: changes for shift + trident + mtv integration --- deploy/bundle.yaml | 2 +- ...identorchestrator_cr_imagepullsecrets.yaml | 2 +- deploy/operator.yaml | 2 +- .../controller_helpers/kubernetes/config.go | 11 + .../controller_helpers/kubernetes/helper.go | 207 ++++++++++++++++++ .../csi/controller_helpers/plain/plugin.go | 5 + frontend/csi/controller_helpers/types.go | 5 + frontend/csi/controller_server.go | 85 +++++++ frontend/csi/plugin.go | 4 + frontend/csi/shift/client.go | 204 +++++++++++++++++ .../mock_controller_helpers.go | 14 ++ storage/volume.go | 18 ++ 12 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 frontend/csi/shift/client.go diff --git a/deploy/bundle.yaml b/deploy/bundle.yaml index aa5e34bb4..d93e02dbe 100644 --- a/deploy/bundle.yaml +++ b/deploy/bundle.yaml @@ -503,7 +503,7 @@ spec: fieldPath: metadata.name - name: OPERATOR_NAME value: trident-operator - image: docker.io/netapp/trident-operator:26.06.0 + image: quay.io/pradeepkumar27x/trident-operator:26.06.0-custom-linux-amd64 imagePullPolicy: IfNotPresent name: trident-operator resources: diff --git a/deploy/crds/tridentorchestrator_cr_imagepullsecrets.yaml b/deploy/crds/tridentorchestrator_cr_imagepullsecrets.yaml index 6f71e0636..2daa7e965 100644 --- a/deploy/crds/tridentorchestrator_cr_imagepullsecrets.yaml +++ b/deploy/crds/tridentorchestrator_cr_imagepullsecrets.yaml @@ -5,6 +5,6 @@ metadata: spec: debug: true namespace: trident - tridentImage: netapp/trident:26.06.0 + tridentImage: quay.io/pradeepkumar27x/trident:26.06.0-custom-linux-amd64 imagePullSecrets: - thisisasecret diff --git a/deploy/operator.yaml b/deploy/operator.yaml index 68537623c..4d43242d4 100644 --- a/deploy/operator.yaml +++ b/deploy/operator.yaml @@ -22,7 +22,7 @@ spec: serviceAccountName: trident-operator containers: - name: trident-operator - image: docker.io/netapp/trident-operator:26.06.0 + image: quay.io/pradeepkumar27x/trident-operator:26.06.0-custom-linux-amd64 command: - "/trident-operator" - "--debug" diff --git a/frontend/csi/controller_helpers/kubernetes/config.go b/frontend/csi/controller_helpers/kubernetes/config.go index ba269353f..f19531b89 100644 --- a/frontend/csi/controller_helpers/kubernetes/config.go +++ b/frontend/csi/controller_helpers/kubernetes/config.go @@ -81,6 +81,17 @@ const ( AnnTieringPolicy = prefix + "/tieringPolicy" AnnTieringMinimumCoolingDays = prefix + "/tieringMinimumCoolingDays" + // Shift/MTV StorageClass annotations + AnnShiftStorageClassType = "shift.netapp.io/storage-class-type" + AnnShiftTridentBackendUUID = "shift.netapp.io/trident-backend-uuid" + + // MTV PVC annotations + AnnMTVDiskPath = "mtv.redhat.com/disk-path" + AnnMTVNFSServer = "mtv.redhat.com/nfs-server" + AnnMTVNFSPath = "mtv.redhat.com/nfs-path" + AnnMTVVMID = "mtv.redhat.com/vm-id" + AnnMTVVMUUID = "mtv.redhat.com/vm-uuid" + // Pod remediation policy annotation and values AnnPodRemediationPolicyAnnotation = prefix + "/podRemediationPolicy" PodRemediationPolicyDelete = "delete" diff --git a/frontend/csi/controller_helpers/kubernetes/helper.go b/frontend/csi/controller_helpers/kubernetes/helper.go index 13fda5a28..ef8427b0a 100644 --- a/frontend/csi/controller_helpers/kubernetes/helper.go +++ b/frontend/csi/controller_helpers/kubernetes/helper.go @@ -4,6 +4,7 @@ package kubernetes import ( "context" + "encoding/json" "fmt" "strconv" "strings" @@ -15,6 +16,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" "github.com/netapp/trident/config" frontendcommon "github.com/netapp/trident/frontend/common" @@ -99,6 +101,31 @@ func (h *helper) GetVolumeConfig( volumeConfig := getVolumeConfig(ctx, pvc, pvName, pvcSize, annotations, sc, requisiteTopology, preferredTopology) + // Detect Shift StorageClass and populate ShiftConfig with ONTAP credentials + MTV metadata + if scAnnotations[AnnShiftStorageClassType] == "shift" { + Logc(ctx).WithFields(LogFields{ + "storageClass": sc.Name, + "pvc": pvc.Name, + }).Info("Shift StorageClass detected, resolving ONTAP credentials for Shift integration.") + + shiftCfg, shiftErr := h.buildShiftConfig(ctx, pvc, scAnnotations) + if shiftErr != nil { + return nil, fmt.Errorf("failed to build Shift config for PVC %s: %v", pvc.Name, shiftErr) + } + volumeConfig.Shift = shiftCfg + + Logc(ctx).WithFields(LogFields{ + "backendUUID": shiftCfg.BackendUUID, + "managementLIF": shiftCfg.ManagementLIF, + "svm": shiftCfg.SVM, + "diskPath": shiftCfg.DiskPath, + "nfsServer": shiftCfg.NFSServer, + "nfsPath": shiftCfg.NFSPath, + "vmID": shiftCfg.VMID, + "vmUUID": shiftCfg.VMUUID, + }).Info("Shift config populated on VolumeConfig.") + } + // Update the volume config with the Access Control only if the storage class nasType parameter is SMB if sc.Parameters[SCParameterNASType] == NASTypeSMB { err = h.updateVolumeConfigWithSecureSMBAccessControl(ctx, volumeConfig, sc, annotations, scAnnotations, secrets) @@ -753,6 +780,42 @@ func (h *helper) RecordVolumeEvent(ctx context.Context, name, eventType, reason, } } +// PatchVolumeAnnotations merges the supplied annotations into the PVC identified by the +// given CSI volume name (pvc-). Existing annotations are preserved; only the supplied +// keys are added or overwritten. +func (h *helper) PatchVolumeAnnotations( + ctx context.Context, name string, annotations map[string]string, +) error { + pvc, err := h.getPVCForCSIVolume(ctx, name) + if err != nil { + return fmt.Errorf("failed to find PVC for volume %s: %v", name, err) + } + + patch := map[string]interface{}{ + "metadata": map[string]interface{}{ + "annotations": annotations, + }, + } + patchBytes, err := json.Marshal(patch) + if err != nil { + return fmt.Errorf("failed to marshal annotation patch: %v", err) + } + + _, err = h.kubeClient.CoreV1().PersistentVolumeClaims(pvc.Namespace).Patch( + ctx, pvc.Name, k8stypes.MergePatchType, patchBytes, metav1.PatchOptions{}) + if err != nil { + return fmt.Errorf("failed to patch PVC %s/%s annotations: %v", pvc.Namespace, pvc.Name, err) + } + + Logc(ctx).WithFields(LogFields{ + "pvc": pvc.Name, + "namespace": pvc.Namespace, + "annotations": annotations, + }).Info("Patched PVC annotations successfully.") + + return nil +} + // RecordNodeEvent accepts the name of a CSI volume (i.e. a PV name), finds the associated // PVC, and posts and event message on the PVC object with the K8S API server. func (h *helper) RecordNodeEvent(ctx context.Context, name, eventType, reason, message string) { @@ -965,6 +1028,150 @@ func processSCAnnotations(sc *k8sstoragev1.StorageClass) map[string]string { return annotations } +// buildShiftConfig resolves all information needed for the Shift integration: +// ONTAP credentials from the TBC secret, MTV metadata from PVC annotations, +// and the Shift endpoint from StorageClass annotations. +func (h *helper) buildShiftConfig( + ctx context.Context, + pvc *v1.PersistentVolumeClaim, + scAnnotations map[string]string, +) (*storage.ShiftConfig, error) { + backendRef := scAnnotations[AnnShiftTridentBackendUUID] + if backendRef == "" { + return nil, fmt.Errorf("StorageClass missing %s annotation", AnnShiftTridentBackendUUID) + } + + pvcAnn := pvc.Annotations + diskPath := pvcAnn[AnnMTVDiskPath] + nfsServer := pvcAnn[AnnMTVNFSServer] + nfsPath := pvcAnn[AnnMTVNFSPath] + + if diskPath == "" || nfsServer == "" || nfsPath == "" { + return nil, fmt.Errorf("PVC %s missing required MTV annotations (disk-path, nfs-server, nfs-path)", pvc.Name) + } + + // Resolve ONTAP connection info from the backend (accepts name or UUID) + mgmtLIF, svm, username, password, err := h.resolveOntapCredentials(ctx, backendRef) + if err != nil { + return nil, fmt.Errorf("failed to resolve ONTAP credentials for backend %s: %v", backendRef, err) + } + + return &storage.ShiftConfig{ + BackendUUID: backendRef, + ManagementLIF: mgmtLIF, + SVM: svm, + Username: username, + Password: password, + DiskPath: diskPath, + NFSServer: nfsServer, + NFSPath: nfsPath, + VMID: pvcAnn[AnnMTVVMID], + VMUUID: pvcAnn[AnnMTVVMUUID], + PVCUID: string(pvc.UID), + }, nil +} + +// resolveOntapCredentials fetches the ManagementLIF and SVM from the BackendExternal, +// then reads the ONTAP username/password from the Kubernetes Secret referenced by the TBC. +// The backendRef may be a backend name or a backend UUID; both are tried. +func (h *helper) resolveOntapCredentials( + ctx context.Context, backendRef string, +) (mgmtLIF, svm, username, password string, err error) { + + Logc(ctx).WithField("backendRef", backendRef).Debug("Shift: looking up backend by name first, then by UUID.") + + backendExt, err := h.orchestrator.GetBackend(ctx, backendRef) + if err != nil { + Logc(ctx).WithField("backendRef", backendRef).Debug("Shift: GetBackend by name failed, trying GetBackendByBackendUUID.") + backendExt, err = h.orchestrator.GetBackendByBackendUUID(ctx, backendRef) + if err != nil { + return "", "", "", "", fmt.Errorf("backend %s not found by name or UUID: %v", backendRef, err) + } + } + + Logc(ctx).WithFields(LogFields{ + "backendName": backendExt.Name, + "backendUUID": backendExt.BackendUUID, + "configRef": backendExt.ConfigRef, + }).Debug("Shift: resolved backend for credential lookup.") + + // Extract non-sensitive fields (ManagementLIF, SVM) from the external config. + // The external config is a map[string]interface{} when JSON-round-tripped. + configJSON, jsonErr := json.Marshal(backendExt.Config) + if jsonErr != nil { + return "", "", "", "", fmt.Errorf("cannot marshal backend config: %v", jsonErr) + } + var parsed map[string]interface{} + if jsonErr = json.Unmarshal(configJSON, &parsed); jsonErr != nil { + return "", "", "", "", fmt.Errorf("cannot unmarshal backend config: %v", jsonErr) + } + + if v, ok := parsed["managementLIF"].(string); ok { + mgmtLIF = v + } + if v, ok := parsed["svm"].(string); ok { + svm = v + } + + Logc(ctx).WithFields(LogFields{ + "managementLIF": mgmtLIF, + "svm": svm, + }).Debug("Shift: extracted ONTAP connection info from backend config.") + + // The configRef on the backend is the TBC's Kubernetes UID, not its name. + // List all TBCs and find the one whose UID matches. + configRef := backendExt.ConfigRef + if configRef == "" { + return "", "", "", "", fmt.Errorf("backend %s has no configRef (TBC)", backendRef) + } + + tbcList, tbcErr := h.tridentClient.TridentV1().TridentBackendConfigs(h.namespace).List(ctx, listOpts) + if tbcErr != nil { + return "", "", "", "", fmt.Errorf("failed to list TBCs: %v", tbcErr) + } + + var secretName string + for _, tbc := range tbcList.Items { + if string(tbc.UID) == configRef { + Logc(ctx).WithFields(LogFields{ + "tbcName": tbc.Name, + "tbcUID": tbc.UID, + "configRef": configRef, + }).Debug("Shift: found TBC matching backend configRef.") + + sName, sErr := tbc.Spec.GetSecretName() + if sErr != nil { + return "", "", "", "", fmt.Errorf("failed to get secret name from TBC %s: %v", tbc.Name, sErr) + } + secretName = sName + break + } + } + + if secretName == "" { + return "", "", "", "", fmt.Errorf("no TBC found with UID %s, or TBC has no credentials secret", configRef) + } + + Logc(ctx).WithFields(LogFields{ + "secretName": secretName, + "namespace": h.namespace, + }).Debug("Shift: reading credentials secret.") + + secret, secretErr := h.kubeClient.CoreV1().Secrets(h.namespace).Get(ctx, secretName, getOpts) + if secretErr != nil { + return "", "", "", "", fmt.Errorf("failed to read secret %s/%s: %v", h.namespace, secretName, secretErr) + } + + username = string(secret.Data["username"]) + password = string(secret.Data["password"]) + if username == "" || password == "" { + return "", "", "", "", fmt.Errorf("secret %s missing username or password", secretName) + } + + Logc(ctx).Debug("Shift: successfully resolved ONTAP credentials from TBC secret.") + return mgmtLIF, svm, username, password, nil +} + // getSMBShareAccessControlFromPVCAnnotation parses the smbShareAccessControl annotation and updates the smbShareACL map func getSMBShareAccessControlFromPVCAnnotation(smbShareAccessControlAnn string) (map[string]string, error) { // Structure to hold the parsed smbShareAccessControlAnnotation diff --git a/frontend/csi/controller_helpers/plain/plugin.go b/frontend/csi/controller_helpers/plain/plugin.go index 52a4386e3..9064e8d0b 100644 --- a/frontend/csi/controller_helpers/plain/plugin.go +++ b/frontend/csi/controller_helpers/plain/plugin.go @@ -151,6 +151,11 @@ func (h *helper) RecordVolumeEvent(ctx context.Context, name, eventType, reason, }).Trace("Volume event.") } +// PatchVolumeAnnotations is a no-op in plain CSI mode (no Kubernetes API). +func (h *helper) PatchVolumeAnnotations(_ context.Context, _ string, _ map[string]string) error { + return nil +} + // RecordNodeEvent accepts the name of a CSI node and writes the specified // event message to the debug Log(). func (h *helper) RecordNodeEvent(ctx context.Context, name, eventType, reason, message string) { diff --git a/frontend/csi/controller_helpers/types.go b/frontend/csi/controller_helpers/types.go index 24d7efb54..00a77b09e 100644 --- a/frontend/csi/controller_helpers/types.go +++ b/frontend/csi/controller_helpers/types.go @@ -62,6 +62,11 @@ type ControllerHelper interface { // event message in a manner appropriate to the container orchestrator. RecordVolumeEvent(ctx context.Context, name, eventType, reason, message string) + // PatchVolumeAnnotations merges the supplied annotations into the PVC identified + // by the given CSI volume name (pvc-). Existing annotations are preserved; + // only the supplied keys are added or overwritten. + PatchVolumeAnnotations(ctx context.Context, name string, annotations map[string]string) error + // RecordNodeEvent accepts the name of a CSI node and writes the specified // event message in a manner appropriate to the container orchestrator. RecordNodeEvent(ctx context.Context, name, eventType, reason, message string) diff --git a/frontend/csi/controller_server.go b/frontend/csi/controller_server.go index 42a65e542..c401a4916 100644 --- a/frontend/csi/controller_server.go +++ b/frontend/csi/controller_server.go @@ -20,6 +20,7 @@ import ( tridentconfig "github.com/netapp/trident/config" controllerhelpers "github.com/netapp/trident/frontend/csi/controller_helpers" + "github.com/netapp/trident/frontend/csi/shift" . "github.com/netapp/trident/logging" "github.com/netapp/trident/pkg/capacity" "github.com/netapp/trident/pkg/collection" @@ -239,6 +240,90 @@ func (p *Plugin) CreateVolume( return nil, p.getCSIErrorForOrchestratorError(err) } + // --- Shift integration: intercept before clone/import/create decision --- + // Only triggered when StorageClass has annotation shift.netapp.io/storage-class-type: "shift". + // Normal PVCs (without that annotation) will have volConfig.Shift == nil and skip this block entirely. + // If the PVC already has import annotations (Shift completed previously), skip straight to import. + if volConfig.Shift != nil && volConfig.ImportOriginalName == "" { + Logc(ctx).WithFields(LogFields{ + "pvcName": volConfig.RequestName, + "pvcNamespace": volConfig.Namespace, + "pvcUID": volConfig.Shift.PVCUID, + "storageClass": volConfig.StorageClass, + "backendUUID": volConfig.Shift.BackendUUID, + "managementLIF": volConfig.Shift.ManagementLIF, + "svm": volConfig.Shift.SVM, + "diskPath": volConfig.Shift.DiskPath, + "nfsServer": volConfig.Shift.NFSServer, + "nfsPath": volConfig.Shift.NFSPath, + }).Info("Shift: PVC targets a Shift StorageClass -- entering Shift flow.") + + shiftResp, shiftErr := p.shiftClient.InvokeShiftJob(ctx, volConfig.Shift) + if shiftErr != nil { + msg := fmt.Sprintf("Shift API call failed for PVC %s: %v", req.Name, shiftErr) + Logc(ctx).Error(msg) + p.controllerHelper.RecordVolumeEvent(ctx, req.Name, controllerhelpers.EventTypeWarning, + "ShiftJobFailed", msg) + return nil, status.Error(codes.Internal, msg) + } + + switch shiftResp.JobStatus() { + case shift.JobStatusSuccess: + clonedVol := shiftResp.VolumeName() + Logc(ctx).WithFields(LogFields{ + "clonedVolumeName": clonedVol, + "id": shiftResp.ID, + }).Info("Shift: VM disk conversion succeeded, patching PVC with import annotations.") + + importAnnotations := map[string]string{ + "trident.netapp.io/importOriginalName": clonedVol, + "trident.netapp.io/importBackendUUID": volConfig.Shift.BackendUUID, + "trident.netapp.io/notManaged": "false", + "trident.netapp.io/importNoRename": "true", + } + if err := p.controllerHelper.PatchVolumeAnnotations(ctx, req.Name, importAnnotations); err != nil { + msg := fmt.Sprintf("Shift conversion succeeded but failed to patch PVC annotations: %v", err) + Logc(ctx).Error(msg) + return nil, status.Error(codes.Internal, msg) + } + + p.controllerHelper.RecordVolumeEvent(ctx, req.Name, controllerhelpers.EventTypeNormal, + "ShiftConversionSucceeded", + fmt.Sprintf("NetApp Shift VM disk conversion completed; volume %s is ready for import from backend %s", + clonedVol, volConfig.Shift.BackendUUID)) + + return nil, status.Errorf(codes.Aborted, + "NetApp Shift conversion completed for PVC %s; retrying to trigger volume import", req.Name) + + case shift.JobStatusRunning: + Logc(ctx).WithFields(LogFields{ + "id": shiftResp.ID, + "href": shiftResp.Href, + }).Info("Shift: VM disk conversion in progress, waiting for completion.") + + p.controllerHelper.RecordVolumeEvent(ctx, req.Name, controllerhelpers.EventTypeNormal, + "ShiftConversionInProgress", + fmt.Sprintf("NetApp Shift VM disk conversion is in progress for PVC %s; provisioning will resume automatically once the conversion completes", + req.Name)) + return nil, status.Errorf(codes.DeadlineExceeded, + "NetApp Shift VM disk conversion is in progress for PVC %s; provisioning will resume automatically once the conversion completes", + req.Name) + + case shift.JobStatusFailed: + msg := fmt.Sprintf("NetApp Shift VM disk conversion failed for PVC %s: %s", + req.Name, shiftResp.Message) + Logc(ctx).Error(msg) + p.controllerHelper.RecordVolumeEvent(ctx, req.Name, controllerhelpers.EventTypeWarning, + "ShiftConversionFailed", msg) + return nil, status.Error(codes.Internal, msg) + + default: + msg := fmt.Sprintf("NetApp Shift returned an unexpected response for PVC %s", req.Name) + Logc(ctx).Error(msg) + return nil, status.Error(codes.Internal, msg) + } + } + // Check if CSI asked for a clone (overrides trident.netapp.io/cloneFromPVC PVC annotation, if present) if req.VolumeContentSource != nil { switch contentSource := req.VolumeContentSource.Type.(type) { diff --git a/frontend/csi/plugin.go b/frontend/csi/plugin.go index fba9c092e..96b3620f1 100644 --- a/frontend/csi/plugin.go +++ b/frontend/csi/plugin.go @@ -20,6 +20,7 @@ import ( controllerAPI "github.com/netapp/trident/frontend/csi/controller_api" controllerhelpers "github.com/netapp/trident/frontend/csi/controller_helpers" nodehelpers "github.com/netapp/trident/frontend/csi/node_helpers" + "github.com/netapp/trident/frontend/csi/shift" . "github.com/netapp/trident/logging" "github.com/netapp/trident/utils/devices" "github.com/netapp/trident/utils/errors" @@ -60,6 +61,7 @@ type Plugin struct { restClient controllerAPI.TridentController controllerHelper controllerhelpers.ControllerHelper nodeHelper nodehelpers.NodeHelper + shiftClient shift.Client aesKey []byte @@ -126,6 +128,7 @@ func NewControllerPlugin( command: execCmd.NewCommand(), osutils: osutils.New(), activatedChan: make(chan struct{}, 1), + shiftClient: shift.NewClient(), } var err error @@ -336,6 +339,7 @@ func NewAllInOnePlugin( command: execCmd.NewCommand(), osutils: osutils.New(), activatedChan: make(chan struct{}, 1), + shiftClient: shift.NewClient(), } port := "34571" diff --git a/frontend/csi/shift/client.go b/frontend/csi/shift/client.go new file mode 100644 index 000000000..356bb9a29 --- /dev/null +++ b/frontend/csi/shift/client.go @@ -0,0 +1,204 @@ +// Copyright 2025 NetApp, Inc. All Rights Reserved. + +package shift + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + . "github.com/netapp/trident/logging" + "github.com/netapp/trident/storage" +) + +// Shift service in-cluster endpoint. +// The service is a NodePort service named "shift-service" in namespace "shift". +// From inside the cluster we use the ClusterIP DNS name directly. +const ShiftServiceURL = "https://shift-service.shift.svc.cluster.local:3704/api/recovery/conversion-pvc" + +// Shift API numeric status codes +const ( + StatusRunning = 3 + StatusSuccess = 4 + StatusFailed = 5 +) + +// JobStatus represents the logical status of a Shift job. +type JobStatus string + +const ( + JobStatusSuccess JobStatus = "success" + JobStatusRunning JobStatus = "running" + JobStatusFailed JobStatus = "failed" +) + +// --- Shift API request payload --- + +type OntapCredentials struct { + EndPoint string `json:"endPoint"` + LoginID string `json:"loginId"` + Password string `json:"password"` + SkipSSLValidation bool `json:"skipSSLValidation"` +} + +type PVCRef struct { + UID string `json:"uid"` +} + +type ShiftOptions struct { + TimeoutSecs int `json:"timeoutSecs"` +} + +type Request struct { + NFSSharePath string `json:"nfsSharePath"` + SVMName string `json:"svmName"` + DiskFileName string `json:"diskFileName"` + DatastoreRemotePath string `json:"datastoreRemotePath"` + OntapCredentials OntapCredentials `json:"ontapCredentials"` + PVC PVCRef `json:"pvc"` + PlanType string `json:"planType"` + Options ShiftOptions `json:"options"` +} + +// --- Shift API response payload --- + +type Response struct { + ID string `json:"id"` + InternalID string `json:"_id"` + Href string `json:"href"` + Status int `json:"status"` + ClonedVolumeName *string `json:"clonedVolumeName"` + Message string `json:"message"` +} + +func (r *Response) JobStatus() JobStatus { + switch r.Status { + case StatusSuccess: + return JobStatusSuccess + case StatusRunning: + return JobStatusRunning + case StatusFailed: + return JobStatusFailed + default: + if r.Href != "" && r.Status == 0 { + return JobStatusRunning + } + return JobStatusFailed + } +} + +func (r *Response) VolumeName() string { + if r.ClonedVolumeName != nil { + return *r.ClonedVolumeName + } + return "" +} + +// Client is the interface for invoking Shift jobs. +type Client interface { + InvokeShiftJob(ctx context.Context, shiftCfg *storage.ShiftConfig) (*Response, error) +} + +// --- Real HTTP client --- + +type httpClient struct { + client *http.Client +} + +func NewClient() Client { + return &httpClient{ + client: &http.Client{ + Timeout: 60 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 + }, + }, + } +} + +func (c *httpClient) InvokeShiftJob( + ctx context.Context, shiftCfg *storage.ShiftConfig, +) (*Response, error) { + + reqBody := &Request{ + NFSSharePath: shiftCfg.NFSPath, + SVMName: shiftCfg.SVM, + DiskFileName: shiftCfg.DiskPath, + DatastoreRemotePath: shiftCfg.NFSPath, + OntapCredentials: OntapCredentials{ + EndPoint: shiftCfg.ManagementLIF, + LoginID: shiftCfg.Username, + Password: shiftCfg.Password, + SkipSSLValidation: true, + }, + PVC: PVCRef{ + UID: shiftCfg.PVCUID, + }, + PlanType: "openshift-mtv", + Options: ShiftOptions{ + TimeoutSecs: 3600, + }, + } + + payload, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("shift: failed to marshal request: %v", err) + } + + Logc(ctx).WithFields(LogFields{ + "endpoint": ShiftServiceURL, + "pvcUID": shiftCfg.PVCUID, + "svm": shiftCfg.SVM, + "managementLIF": shiftCfg.ManagementLIF, + "nfsPath": shiftCfg.NFSPath, + "diskPath": shiftCfg.DiskPath, + }).Info("Shift: invoking Shift REST endpoint.") + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, ShiftServiceURL, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("shift: failed to create HTTP request: %v", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := c.client.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("shift: HTTP request failed: %v", err) + } + defer httpResp.Body.Close() + + body, err := io.ReadAll(httpResp.Body) + if err != nil { + return nil, fmt.Errorf("shift: failed to read response body: %v", err) + } + + Logc(ctx).WithFields(LogFields{ + "httpStatus": httpResp.StatusCode, + "responseBody": string(body), + }).Info("Shift: received response from Shift endpoint.") + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return nil, fmt.Errorf("shift: endpoint returned HTTP %d: %s", httpResp.StatusCode, string(body)) + } + + var resp Response + if err = json.Unmarshal(body, &resp); err != nil { + return nil, fmt.Errorf("shift: failed to unmarshal response: %v", err) + } + + Logc(ctx).WithFields(LogFields{ + "status": resp.Status, + "jobStatus": resp.JobStatus(), + "clonedVolumeName": resp.VolumeName(), + "id": resp.ID, + "internalID": resp.InternalID, + "href": resp.Href, + "message": resp.Message, + }).Info("Shift: parsed Shift response.") + + return &resp, nil +} diff --git a/mocks/mock_frontend/mock_csi/mock_controller_helpers/mock_controller_helpers.go b/mocks/mock_frontend/mock_csi/mock_controller_helpers/mock_controller_helpers.go index 3065a64a7..bc9493132 100644 --- a/mocks/mock_frontend/mock_csi/mock_controller_helpers/mock_controller_helpers.go +++ b/mocks/mock_frontend/mock_csi/mock_controller_helpers/mock_controller_helpers.go @@ -197,6 +197,20 @@ func (mr *MockControllerHelperMockRecorder) RecordVolumeEvent(arg0, arg1, arg2, return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordVolumeEvent", reflect.TypeOf((*MockControllerHelper)(nil).RecordVolumeEvent), arg0, arg1, arg2, arg3, arg4) } +// PatchVolumeAnnotations mocks base method. +func (m *MockControllerHelper) PatchVolumeAnnotations(arg0 context.Context, arg1 string, arg2 map[string]string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "PatchVolumeAnnotations", arg0, arg1, arg2) + ret0, _ := ret[0].(error) + return ret0 +} + +// PatchVolumeAnnotations indicates an expected call of PatchVolumeAnnotations. +func (mr *MockControllerHelperMockRecorder) PatchVolumeAnnotations(arg0, arg1, arg2 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "PatchVolumeAnnotations", reflect.TypeOf((*MockControllerHelper)(nil).PatchVolumeAnnotations), arg0, arg1, arg2) +} + // SupportsFeature mocks base method. func (m *MockControllerHelper) SupportsFeature(arg0 context.Context, arg1 controllerhelpers.Feature) bool { m.ctrl.T.Helper() diff --git a/storage/volume.go b/storage/volume.go index f02d64080..649e230d6 100644 --- a/storage/volume.go +++ b/storage/volume.go @@ -14,6 +14,22 @@ import ( "github.com/netapp/trident/utils/models" ) +// ShiftConfig holds all metadata needed for the NetApp Shift integration. +// Fields are transient (json:"-") so they are never persisted to CRDs. +type ShiftConfig struct { + BackendUUID string `json:"-"` + ManagementLIF string `json:"-"` + SVM string `json:"-"` + Username string `json:"-"` + Password string `json:"-"` + DiskPath string `json:"-"` + NFSServer string `json:"-"` + NFSPath string `json:"-"` + VMID string `json:"-"` + VMUUID string `json:"-"` + PVCUID string `json:"-"` +} + type VolumeConfig struct { Version string `json:"version"` Name string `json:"name"` @@ -80,6 +96,8 @@ type VolumeConfig struct { // RequestedAutogrowPolicy stores the autogrow policy on volume // This IS persisted so we can recompute the effective policy after restart RequestedAutogrowPolicy string `json:"requestedAutogrowPolicy,omitempty"` + // Shift holds transient shift-integration metadata; never persisted. + Shift *ShiftConfig `json:"-"` } type VolumeCreatingConfig struct {