From 6cc95efd2d5462131a6da9f6a3dc65c6db380506 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 24 Nov 2025 16:02:29 +0000 Subject: [PATCH 1/2] Initial commit for the preserveUnlink functionality. --- frontend/common/volumes.go | 12 ++++ .../controller_helpers/kubernetes/config.go | 1 + storage/volume.go | 1 + storage_drivers/ontap/api/abstraction.go | 1 + storage_drivers/ontap/api/abstraction_rest.go | 8 +++ storage_drivers/ontap/api/abstraction_zapi.go | 6 ++ storage_drivers/ontap/api/ontap_rest.go | 61 +++++++++++++++++++ .../ontap/api/ontap_rest_interface.go | 2 + storage_drivers/ontap/api/types.go | 1 + storage_drivers/ontap/ontap_common.go | 47 ++++++++++++++ storage_drivers/ontap/ontap_nas.go | 15 ++++- storage_drivers/types.go | 1 + 12 files changed, 155 insertions(+), 1 deletion(-) diff --git a/frontend/common/volumes.go b/frontend/common/volumes.go index ca9efd0cb..9d87c3aad 100644 --- a/frontend/common/volumes.go +++ b/frontend/common/volumes.go @@ -138,6 +138,17 @@ func GetVolumeConfig( snapshotDir = snapDirFormatted } + // If preserveUnlink is provided, ensure it is lower case + preserveUnlink := collection.GetV(opts, "preserveUnlink", "") + if preserveUnlink != "" { + preserveUnlinkFormatted, err := convert.ToFormattedBool(preserveUnlink) + if err != nil { + Logc(ctx).WithError(err).Errorf("Invalid boolean value for preserveUnlink: %v.", preserveUnlink) + return nil, err + } + preserveUnlink = preserveUnlinkFormatted + } + cfg := &storage.VolumeConfig{ Name: name, Size: fmt.Sprintf("%d", sizeBytes), @@ -150,6 +161,7 @@ func GetVolumeConfig( SplitOnClone: collection.GetV(opts, "splitOnClone", ""), SnapshotPolicy: collection.GetV(opts, "snapshotPolicy", ""), SnapshotReserve: collection.GetV(opts, "snapshotReserve", ""), + PreserveUnlink: preserveUnlink, SnapshotDir: snapshotDir, ExportPolicy: collection.GetV(opts, "exportPolicy", ""), UnixPermissions: collection.GetV(opts, "unixPermissions", ""), diff --git a/frontend/csi/controller_helpers/kubernetes/config.go b/frontend/csi/controller_helpers/kubernetes/config.go index 327d8ae11..a4c87fead 100644 --- a/frontend/csi/controller_helpers/kubernetes/config.go +++ b/frontend/csi/controller_helpers/kubernetes/config.go @@ -49,6 +49,7 @@ const ( AnnSnapshotPolicy = prefix + "/snapshotPolicy" AnnSnapshotReserve = prefix + "/snapshotReserve" AnnSnapshotDir = prefix + "/snapshotDirectory" + AnnPreserveUnlink = prefix + "/preserveUnlink" AnnUnixPermissions = prefix + "/unixPermissions" AnnExportPolicy = prefix + "/exportPolicy" AnnBlockSize = prefix + "/blockSize" diff --git a/storage/volume.go b/storage/volume.go index cf02342d5..3e698d28a 100644 --- a/storage/volume.go +++ b/storage/volume.go @@ -24,6 +24,7 @@ type VolumeConfig struct { SecurityStyle string `json:"securityStyle"` SnapshotPolicy string `json:"snapshotPolicy,omitempty"` SnapshotReserve string `json:"snapshotReserve,omitempty"` + PreserveUnlink string `json:"preserveUnlink,omitempty"` SnapshotDir string `json:"snapshotDirectory,omitempty"` ExportPolicy string `json:"exportPolicy,omitempty"` UnixPermissions string `json:"unixPermissions,omitempty"` diff --git a/storage_drivers/ontap/api/abstraction.go b/storage_drivers/ontap/api/abstraction.go index 6a6295042..e27d6257e 100644 --- a/storage_drivers/ontap/api/abstraction.go +++ b/storage_drivers/ontap/api/abstraction.go @@ -233,6 +233,7 @@ type OntapAPI interface { ) (string, error) VolumeRecoveryQueuePurge(ctx context.Context, recoveryQueueVolumeName string) error VolumeRecoveryQueueGetName(ctx context.Context, name string) (string, error) + PreserveUnlinkSet(ctx context.Context, volumeName string) error SMBShareCreate(ctx context.Context, shareName, path string) error SMBShareExists(ctx context.Context, shareName string) (bool, error) SMBShareDestroy(ctx context.Context, shareName string) error diff --git a/storage_drivers/ontap/api/abstraction_rest.go b/storage_drivers/ontap/api/abstraction_rest.go index 8e03e48a5..07a2633a7 100644 --- a/storage_drivers/ontap/api/abstraction_rest.go +++ b/storage_drivers/ontap/api/abstraction_rest.go @@ -220,6 +220,14 @@ func (d OntapAPIREST) VolumeRecoveryQueueGetName(ctx context.Context, name strin return recoveryQueueVolumeName, nil } +func (d OntapAPIREST) PreserveUnlinkSet(ctx context.Context, volumeName string) error { + preserveUnlinkErr := d.api.PreserveUnlinkSet(ctx, volumeName) + if preserveUnlinkErr != nil { + return fmt.Errorf("error setting preserveUnlink %v: %v", volumeName, preserveUnlinkErr) + } + return nil +} + func (d OntapAPIREST) VolumeInfo(ctx context.Context, name string) (*Volume, error) { fields := []string{ "type", "size", "comment", "aggregates", "nas", "guarantee", diff --git a/storage_drivers/ontap/api/abstraction_zapi.go b/storage_drivers/ontap/api/abstraction_zapi.go index 0307c1e35..b0b2cb7ed 100644 --- a/storage_drivers/ontap/api/abstraction_zapi.go +++ b/storage_drivers/ontap/api/abstraction_zapi.go @@ -125,6 +125,12 @@ func (d OntapAPIZAPI) VolumeDestroy(ctx context.Context, name string, force, ski return nil } +// The -is-preserve-unlink-enabled flag is only supported with newer ONTAP versions that use REST. +func (d OntapAPIZAPI) PreserveUnlinkSet(ctx context.Context, volumeName string) error { + Logc(ctx).WithField("volume", volumeName).Warn("preserveUnlink cannot be set with ZAPI.") + return nil +} + func (d OntapAPIZAPI) VolumeRecoveryQueuePurge(ctx context.Context, recoveryQueueVolumeName string) error { volRecoveryQueuePurgeResponse, err := d.api.VolumeRecoveryQueuePurge(recoveryQueueVolumeName) if err != nil { diff --git a/storage_drivers/ontap/api/ontap_rest.go b/storage_drivers/ontap/api/ontap_rest.go index 37f8bc1a2..dd7284858 100644 --- a/storage_drivers/ontap/api/ontap_rest.go +++ b/storage_drivers/ontap/api/ontap_rest.go @@ -1770,6 +1770,67 @@ func (c *RestClient) VolumeRecoveryQueueGetName(ctx context.Context, name string return responseObject.Records[0].Volume, nil } +// PreserveUnlinkSet uses the cli passthrough REST API to set the is-preserve-unlink-enabled option on a volume. +// This volume option was introduced in 9.12.1 but not made visible via the /storage/volumes REST API endpoint. +func (c *RestClient) PreserveUnlinkSet(ctx context.Context, volumeName string) error { + fields := LogFields{ + "Method": "PreserveUnlinkSet", + "Type": "ontap_rest", + "volumeName": volumeName, + "vserver": c.svmName, + } + Logd(ctx, c.driverName, c.config.DebugTraceFlags["method"]).WithFields(fields). + Trace(">>>> PreserveUnlinkSet") + defer Logd(ctx, c.driverName, c.config.DebugTraceFlags["method"]).WithFields(fields). + Trace("<<<< PrserveUnlinkSet") + + requestUrl := fmt.Sprintf("https://%s/api/private/cli/volume?vserver=%s&volume=%s", + c.config.ManagementLIF, c.svmName, volumeName) + + requestContent := map[string]string{ + "is-preserve-unlink-enabled": "true", + } + requestBodyBytes, err := json.Marshal(requestContent) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(ctx, http.MethodPatch, requestUrl, bytes.NewReader(requestBodyBytes)) + if err != nil { + return err + } + + response, err := c.sendPassThroughCliCommand(ctx, request) + if err != nil { + return err + } + + defer func() { _ = response.Body.Close() }() + + var responseBodyBytes []byte + var responseBodyString string + if response.Body != nil { + responseBodyBytes, _ = io.ReadAll(response.Body) + responseBodyString = string(responseBodyBytes) + } else { + Logc(ctx).WithField("statusCode", response.StatusCode).Error( + "no response when trying to set preserveUnlink.") + return fmt.Errorf("no response with status code: %v", response.StatusCode) + } + if response.StatusCode != http.StatusOK { + Logc(ctx).WithField("statusCode", response.StatusCode).Error( + "failed to set preserveUnlink: %s", responseBodyString) + return fmt.Errorf("unexpected response status code: %v", response.StatusCode) + } + if !strings.Contains(responseBodyString, "modify successful") { + Logc(ctx).WithField("statusCode", response.StatusCode).Error( + "unexpected response when setting preserveUnlink: %s", responseBodyString) + return fmt.Errorf("unexpected response boday with status code: %v", response.StatusCode) + } + + return nil +} + func (c *RestClient) sendPassThroughCliCommand(ctx context.Context, request *http.Request) (*http.Response, error) { request.Header.Set("Content-Type", "application/json") diff --git a/storage_drivers/ontap/api/ontap_rest_interface.go b/storage_drivers/ontap/api/ontap_rest_interface.go index b20927a59..b63a002e6 100644 --- a/storage_drivers/ontap/api/ontap_rest_interface.go +++ b/storage_drivers/ontap/api/ontap_rest_interface.go @@ -81,6 +81,8 @@ type RestClientInterface interface { // directly purge the volume from the recovery queue. VolumeRecoveryQueuePurge(ctx context.Context, recoveryQueueVolumeName string) error VolumeRecoveryQueueGetName(ctx context.Context, name string) (string, error) + // PreserveUnlinkSet uses the cli passthrough REST API to to enable the volume is-preserve-unlink-enabled option. + PreserveUnlinkSet(ctx context.Context, volumeName string) error // ConsistencyGroupCreateAndWait creates a CG and waits on the job to complete ConsistencyGroupCreateAndWait(ctx context.Context, cgName string, flexVols []string) error // ConsistencyGroupCreate creates a consistency group diff --git a/storage_drivers/ontap/api/types.go b/storage_drivers/ontap/api/types.go index 7ca16af92..a00838903 100644 --- a/storage_drivers/ontap/api/types.go +++ b/storage_drivers/ontap/api/types.go @@ -20,6 +20,7 @@ type Volume struct { SnapshotDir *bool SnapshotPolicy string SnapshotReserve int + PreserveUnlink *bool SnapshotSpaceUsed int SpaceReserve string TieringPolicy string diff --git a/storage_drivers/ontap/ontap_common.go b/storage_drivers/ontap/ontap_common.go index 6221928fc..14c6a0b74 100644 --- a/storage_drivers/ontap/ontap_common.go +++ b/storage_drivers/ontap/ontap_common.go @@ -92,6 +92,7 @@ const ( SpaceReserve = "spaceReserve" SnapshotPolicy = "snapshotPolicy" SnapshotReserve = "snapshotReserve" + PreserveUnlink = "preserveUnlink" UnixPermissions = "unixPermissions" ExportPolicy = "exportPolicy" SecurityStyle = "securityStyle" @@ -1748,6 +1749,7 @@ const ( DefaultSpaceReserve = "none" DefaultSnapshotPolicy = "none" DefaultSnapshotReserve = "5" + DefaultPreserveUnlink = "false" DefaultUnixPermissions = "---rwxrwxrwx" DefaultSnapshotDir = "false" DefaultExportPolicy = "default" @@ -1812,6 +1814,10 @@ func PopulateConfigurationDefaults(ctx context.Context, config *drivers.OntapSto } } + if config.PreserveUnlink == "" { + config.PreserveUnlink = DefaultPreserveUnlink + } + // If snapshotDir is provided, ensure it is lower case snapDir := DefaultSnapshotDir if config.SnapshotDir != "" { @@ -1822,6 +1828,16 @@ func PopulateConfigurationDefaults(ctx context.Context, config *drivers.OntapSto } config.SnapshotDir = snapDir + // If preserveUnlink is provided, ensure it is lower case + preserveUnlink := DefaultPreserveUnlink + if config.PreserveUnlink != "" { + if preserveUnlink, err = convert.ToFormattedBool(config.PreserveUnlink); err != nil { + Logc(ctx).WithError(err).Errorf("Invalid boolean value for preserveUnlink: %v.", config.PreserveUnlink) + return fmt.Errorf("invalid boolean value for preserveUnlink: %v", err) + } + } + config.PreserveUnlink = preserveUnlink + if config.DenyNewVolumePools == "" { config.DenyNewVolumePools = DefaultDenyNewVolumePools } else { @@ -1950,6 +1966,7 @@ func PopulateConfigurationDefaults(ctx context.Context, config *drivers.OntapSto "SpaceReserve": config.SpaceReserve, "SnapshotPolicy": config.SnapshotPolicy, "SnapshotReserve": config.SnapshotReserve, + "PreserveUnlink": config.PreserveUnlink, "UnixPermissions": config.UnixPermissions, "SnapshotDir": config.SnapshotDir, "ExportPolicy": config.ExportPolicy, @@ -2615,6 +2632,10 @@ func getVolumeExternalCommon( if volume.SnapshotDir != nil { snapshotDir = *volume.SnapshotDir } + preserveUnlink := false + if volume.PreserveUnlink != nil { + preserveUnlink = *volume.PreserveUnlink + } volumeConfig := &storage.VolumeConfig{ Version: tridentconfig.OrchestratorAPIVersion, Name: name, @@ -2625,6 +2646,7 @@ func getVolumeExternalCommon( SnapshotReserve: strconv.Itoa(volume.SnapshotReserve), ExportPolicy: volume.ExportPolicy, SnapshotDir: strconv.FormatBool(snapshotDir), + PreserveUnlink: strconv.FormatBool(preserveUnlink), UnixPermissions: volume.UnixPermissions, StorageClass: "", AccessMode: tridentconfig.ReadWriteMany, @@ -2901,6 +2923,7 @@ func setStoragePoolAttributes( pool.InternalAttributes()[SpaceReserve] = config.SpaceReserve pool.InternalAttributes()[SnapshotPolicy] = config.SnapshotPolicy pool.InternalAttributes()[SnapshotReserve] = config.SnapshotReserve + pool.InternalAttributes()[PreserveUnlink] = config.PreserveUnlink pool.InternalAttributes()[SplitOnClone] = config.SplitOnClone pool.InternalAttributes()[Encryption] = config.Encryption pool.InternalAttributes()[LUKSEncryption] = config.LUKSEncryption @@ -3023,6 +3046,11 @@ func initializeVirtualPools( snapshotReserve = vpool.SnapshotReserve } + preserveUnlink := config.PreserveUnlink + if vpool.PreserveUnlink != "" { + preserveUnlink = vpool.PreserveUnlink + } + splitOnClone := config.SplitOnClone if vpool.SplitOnClone != "" { splitOnClone = vpool.SplitOnClone @@ -3155,6 +3183,7 @@ func initializeVirtualPools( pool.InternalAttributes()[SpaceReserve] = spaceReserve pool.InternalAttributes()[SnapshotPolicy] = snapshotPolicy pool.InternalAttributes()[SnapshotReserve] = snapshotReserve + pool.InternalAttributes()[PreserveUnlink] = preserveUnlink pool.InternalAttributes()[SplitOnClone] = splitOnClone pool.InternalAttributes()[UnixPermissions] = unixPermissions pool.InternalAttributes()[SnapshotDir] = snapshotDir @@ -3696,6 +3725,16 @@ func getVolumeOptsCommon( opts["snapshotDir"] = snapshotDirFormatted } + // If preserveUnlink is provided, ensure it is lower case + if volConfig.PreserveUnlink != "" { + preserveUnlinkFormatted, err := convert.ToFormattedBool(volConfig.PreserveUnlink) + if err != nil { + Logc(ctx).WithError(err).Errorf( + "Invalid boolean value for volume '%v' preserveUnlink: %v.", volConfig.Name, volConfig.PreserveUnlink) + } + opts["preserveUnlink"] = preserveUnlinkFormatted + } + // If skipRecoveryQueue is provided, ensure it is lower case if volConfig.SkipRecoveryQueue != "" { skipRecoveryQueueFormatted, err := convert.ToFormattedBool(volConfig.SkipRecoveryQueue) @@ -5474,6 +5513,14 @@ func purgeRecoveryQueueVolume(ctx context.Context, api api.OntapAPI, volumeName } } +// setPreserveUnlink has to be handled specially +func setPresrveUnlink(ctx context.Context, api api.OntapAPI, volumeName string) { + if err := api.PreserveUnlinkSet(ctx, volumeName); err != nil { + Logc(ctx).WithField("volume", + volumeName).Errorf("error setting preserveUnlink: %v", err) + } +} + // getSMBShareNamePath constructs the SMB share name and path based on the provided parameters. func getSMBShareNamePath(flexvol, name string, secureSMBEnabled bool) (string, string) { if secureSMBEnabled { diff --git a/storage_drivers/ontap/ontap_nas.go b/storage_drivers/ontap/ontap_nas.go index d09921425..05d1575f5 100644 --- a/storage_drivers/ontap/ontap_nas.go +++ b/storage_drivers/ontap/ontap_nas.go @@ -269,6 +269,7 @@ func (d *NASStorageDriver) Create( spaceReserve = collection.GetV(opts, "spaceReserve", storagePool.InternalAttributes()[SpaceReserve]) snapshotPolicy = collection.GetV(opts, "snapshotPolicy", storagePool.InternalAttributes()[SnapshotPolicy]) snapshotReserve = collection.GetV(opts, "snapshotReserve", storagePool.InternalAttributes()[SnapshotReserve]) + preserveUnlink = collection.GetV(opts, "preserveUnlink", storagePool.InternalAttributes()[PreserveUnlink]) unixPermissions = collection.GetV(opts, "unixPermissions", storagePool.InternalAttributes()[UnixPermissions]) snapshotDir = collection.GetV(opts, "snapshotDir", storagePool.InternalAttributes()[SnapshotDir]) exportPolicy = collection.GetV(opts, "exportPolicy", storagePool.InternalAttributes()[ExportPolicy]) @@ -280,7 +281,7 @@ func (d *NASStorageDriver) Create( qosPolicy = storagePool.InternalAttributes()[QosPolicy] adaptiveQosPolicy = storagePool.InternalAttributes()[AdaptiveQosPolicy] ) - + snapshotReserveInt, err := GetSnapshotReserve(snapshotPolicy, snapshotReserve) if err != nil { return fmt.Errorf("invalid value for snapshotReserve: %v", err) @@ -313,6 +314,11 @@ func (d *NASStorageDriver) Create( return fmt.Errorf("invalid boolean value for snapshotDir: %v", err) } + enablePreserveUnlink, err := strconv.ParseBool(preserveUnlink) + if err != nil { + return fmt.Errorf("invalid boolean value for preserveUnlink: %v", err) + } + enableEncryption, configEncryption, err := GetEncryptionValue(encryption) if err != nil { return fmt.Errorf("invalid boolean value for encryption: %v", err) @@ -355,6 +361,7 @@ func (d *NASStorageDriver) Create( volConfig.SpaceReserve = spaceReserve volConfig.SnapshotPolicy = snapshotPolicy volConfig.SnapshotReserve = snapshotReserve + volConfig.PreserveUnlink = preserveUnlink volConfig.UnixPermissions = unixPermissions volConfig.SnapshotDir = snapshotDir volConfig.ExportPolicy = exportPolicy @@ -370,6 +377,7 @@ func (d *NASStorageDriver) Create( "spaceReserve": spaceReserve, "snapshotPolicy": snapshotPolicy, "snapshotReserve": snapshotReserveInt, + "preserveUnlink": enablePreserveUnlink, "unixPermissions": unixPermissions, "snapshotDir": enableSnapshotDir, "exportPolicy": exportPolicy, @@ -463,6 +471,11 @@ func (d *NASStorageDriver) Create( } } + // Set is-unlink-preserve-enabled if required + if enablePreserveUnlink { + setPresrveUnlink(ctx, d.API, volConfig.InternalName) + } + return nil } diff --git a/storage_drivers/types.go b/storage_drivers/types.go index ade6ccbbc..a9cfae82e 100644 --- a/storage_drivers/types.go +++ b/storage_drivers/types.go @@ -198,6 +198,7 @@ type OntapStorageDriverConfigDefaults struct { SpaceReserve string `json:"spaceReserve"` SnapshotPolicy string `json:"snapshotPolicy"` SnapshotReserve string `json:"snapshotReserve"` + PreserveUnlink string `json:"preserveUnlink"` SnapshotDir string `json:"snapshotDir"` UnixPermissions string `json:"unixPermissions"` ExportPolicy string `json:"exportPolicy"` From 884a75cd435389cd052990b0519550635a57abab Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 24 Nov 2025 16:41:30 +0000 Subject: [PATCH 2/2] Finalized the preserveUnlink code. The ONTAP volume setting -is-preserve-unlink-enabled is required for Kafka over NFS use cases. This feature implements a Trident PVC setting to enable this ONTAP volume setting with the ontap-nas driver. To enable this setting with an ontap-nas backend, use the following in the backend definition defaults: "defaults": { "preserveUnlink": "true", } Alternatively, this setting can also be enabled on a per PVC basis with the following PVC annotation: annotations: trident.netapp.io/preserveUnlink: "true" If no value is given for preserveUnlink in the backend or PVC definition, it will default to "false". While the is-preserve-unlink-enabled volume setting was first introduced with ONTAP 9.12.1, this feature in Trident requires the use of ONTAP 9.15.1 or higher where Trident uses the ONTAP REST API by default. The is-preserve-unlink-enabled volume setting in ONTAP is not currently exposed by the ONTAP REST API, so we use the api/private/cli/volume REST endpoint to implement this feature. --- frontend/csi/controller_helpers/kubernetes/helper.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/frontend/csi/controller_helpers/kubernetes/helper.go b/frontend/csi/controller_helpers/kubernetes/helper.go index 87b8bfc26..59ec0b67e 100644 --- a/frontend/csi/controller_helpers/kubernetes/helper.go +++ b/frontend/csi/controller_helpers/kubernetes/helper.go @@ -841,6 +841,16 @@ func getVolumeConfig( snapshotDirAnn = snapDirFormatted } + // If preserveUnlink annotation is provided, ensure it is lower case + preserveUnlinkAnn := getAnnotation(annotations, AnnPreserveUnlink) + if preserveUnlinkAnn != "" { + preserveUnlinkFormatted, err := convert.ToFormattedBool(preserveUnlinkAnn) + if err != nil { + Logc(ctx).WithError(err).Errorf("Invalid boolean value for preserveUnlink annotation: %v.", preserveUnlinkAnn) + } + preserveUnlinkAnn = preserveUnlinkFormatted + } + if getAnnotation(annotations, AnnReadOnlyClone) == "" { annotations[AnnReadOnlyClone] = "false" } @@ -886,6 +896,7 @@ func getVolumeConfig( SnapshotPolicy: getAnnotation(annotations, AnnSnapshotPolicy), SnapshotReserve: getAnnotation(annotations, AnnSnapshotReserve), SnapshotDir: snapshotDirAnn, + PreserveUnlink: preserveUnlinkAnn, ExportPolicy: getAnnotation(annotations, AnnExportPolicy), UnixPermissions: getAnnotation(annotations, AnnUnixPermissions), StorageClass: storageClass.Name,