This is an automated email from the ASF dual-hosted git repository.

exceptionfactory pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 429f9e7348b NIFI-15683 Added Move Confliction Resolution to FetchSFTP 
(#11281)
429f9e7348b is described below

commit 429f9e7348bd7bdb10966a7ed4c389a63eae490b
Author: Rakesh Kumar Singh <[email protected]>
AuthorDate: Thu Aug 6 19:52:05 2026 +0530

    NIFI-15683 Added Move Confliction Resolution to FetchSFTP (#11281)
    
    Co-authored-by: David Handermann <[email protected]>
    Signed-off-by: David Handermann <[email protected]>
---
 .../util/file/transfer/FetchFileTransfer.java      |  56 +++++++++-
 .../apache/nifi/processors/standard/FetchSFTP.java |   1 +
 .../nifi/processors/standard/FetchSFTPTest.java    | 115 +++++++++++++++++++++
 3 files changed, 170 insertions(+), 2 deletions(-)

diff --git 
a/nifi-extension-bundles/nifi-extension-utils/nifi-file-transfer/src/main/java/org/apache/nifi/processor/util/file/transfer/FetchFileTransfer.java
 
b/nifi-extension-bundles/nifi-extension-utils/nifi-file-transfer/src/main/java/org/apache/nifi/processor/util/file/transfer/FetchFileTransfer.java
index 36dfac1fe85..ec429cf9503 100644
--- 
a/nifi-extension-bundles/nifi-extension-utils/nifi-file-transfer/src/main/java/org/apache/nifi/processor/util/file/transfer/FetchFileTransfer.java
+++ 
b/nifi-extension-bundles/nifi-extension-utils/nifi-file-transfer/src/main/java/org/apache/nifi/processor/util/file/transfer/FetchFileTransfer.java
@@ -21,6 +21,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.nifi.annotation.lifecycle.OnScheduled;
 import org.apache.nifi.annotation.lifecycle.OnStopped;
 import org.apache.nifi.components.AllowableValue;
+import org.apache.nifi.components.DescribedValue;
 import org.apache.nifi.components.PropertyDescriptor;
 import org.apache.nifi.expression.ExpressionLanguageScope;
 import org.apache.nifi.flowfile.FlowFile;
@@ -55,6 +56,37 @@ import java.util.concurrent.TimeUnit;
  */
 public abstract class FetchFileTransfer extends AbstractProcessor {
 
+    public enum MoveConflictResolution implements DescribedValue {
+        FAIL("Fail", "Leave the source file unchanged and log failure status"),
+        REPLACE("Replace", """
+                When the destination file already exists, delete it and rename 
the source file to the destination.
+                This option supports SFTP servers such as OpenSSH that do not 
implement atomic overwrite on rename.
+                """);
+
+        MoveConflictResolution(final String displayName, final String 
description) {
+            this.displayName = displayName;
+            this.description = description;
+        }
+
+        private final String displayName;
+        private final String description;
+
+        @Override
+        public String getValue() {
+            return name();
+        }
+
+        @Override
+        public String getDisplayName() {
+            return displayName;
+        }
+
+        @Override
+        public String getDescription() {
+            return description;
+        }
+    }
+
     public static final AllowableValue COMPLETION_NONE = new 
AllowableValue("None", "None", "Leave the file as-is");
     public static final AllowableValue COMPLETION_MOVE = new 
AllowableValue("Move File", "Move File", "Move the file to the directory 
specified by the <Move Destination Directory> property");
     public static final AllowableValue COMPLETION_DELETE = new 
AllowableValue("Delete File", "Delete File", "Deletes the original file from 
the remote system");
@@ -91,8 +123,7 @@ public abstract class FetchFileTransfer extends 
AbstractProcessor {
         .build();
     public static final PropertyDescriptor COMPLETION_STRATEGY = new 
PropertyDescriptor.Builder()
         .name("Completion Strategy")
-        .description("Specifies what to do with the original file on the 
server once it has been pulled into NiFi. If the Completion Strategy fails, a 
warning will be "
-            + "logged but the data will still be transferred.")
+        .description("Specifies what to do with the original file on the 
server once it has been fetched")
         .expressionLanguageSupported(ExpressionLanguageScope.NONE)
         .allowableValues(COMPLETION_NONE, COMPLETION_MOVE, COMPLETION_DELETE)
         .defaultValue(COMPLETION_NONE.getValue())
@@ -116,6 +147,18 @@ public abstract class FetchFileTransfer extends 
AbstractProcessor {
         .required(false)
         .build();
 
+    public static final PropertyDescriptor MOVE_CONFLICT_RESOLUTION = new 
PropertyDescriptor.Builder()
+            .name("Move Conflict Resolution")
+            .description("""
+                    Specifies how to handle the case when the destination file 
already exists when Completion Strategy is set to Move files
+                    """)
+            .expressionLanguageSupported(ExpressionLanguageScope.NONE)
+            .allowableValues(MoveConflictResolution.class)
+            .defaultValue(MoveConflictResolution.FAIL)
+            .dependsOn(COMPLETION_STRATEGY, COMPLETION_MOVE)
+            .required(true)
+            .build();
+
     public static final PropertyDescriptor FILE_NOT_FOUND_LOG_LEVEL = new 
PropertyDescriptor.Builder()
         .name("Log Level When File Not Found")
         .description("Log level to use in case the file does not exist when 
the processor is triggered")
@@ -354,6 +397,7 @@ public abstract class FetchFileTransfer extends 
AbstractProcessor {
         } else if 
(COMPLETION_MOVE.getValue().equalsIgnoreCase(completionStrategy)) {
             final String targetDir = 
context.getProperty(MOVE_DESTINATION_DIR).evaluateAttributeExpressions(flowFile).getValue();
             final String simpleFilename = 
StringUtils.substringAfterLast(filename, "/");
+            final MoveConflictResolution moveConflictResolution = 
context.getProperty(MOVE_CONFLICT_RESOLUTION).asAllowableValue(MoveConflictResolution.class);
 
             try {
                 final String absoluteTargetDirPath = 
transfer.getAbsolutePath(flowFile, targetDir);
@@ -363,6 +407,14 @@ public abstract class FetchFileTransfer extends 
AbstractProcessor {
                 }
 
                 final String destinationPath = String.format("%s/%s", 
absoluteTargetDirPath, simpleFilename);
+                if (MoveConflictResolution.REPLACE == moveConflictResolution) {
+                    final FileInfo existingDestination = 
transfer.getRemoteFileInfo(flowFile, absoluteTargetDirPath, simpleFilename);
+                    if (existingDestination != null) {
+                        getLogger().debug("Destination [{}] already exists: 
removing before rename based on conflict resolution strategy", destinationPath);
+                        transfer.deleteFile(flowFile, null, destinationPath);
+                    }
+                }
+
                 transfer.rename(flowFile, filename, destinationPath);
 
             } catch (final IOException ioe) {
diff --git 
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/FetchSFTP.java
 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/FetchSFTP.java
index 1e21adef703..ae37cdb123c 100644
--- 
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/FetchSFTP.java
+++ 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/main/java/org/apache/nifi/processors/standard/FetchSFTP.java
@@ -108,6 +108,7 @@ public class FetchSFTP extends FetchFileTransfer {
             COMPLETION_STRATEGY,
             MOVE_DESTINATION_DIR,
             MOVE_CREATE_DIRECTORY,
+            MOVE_CONFLICT_RESOLUTION,
             DISABLE_DIRECTORY_LISTING,
             SFTPTransfer.CONNECTION_TIMEOUT,
             SFTPTransfer.DATA_TIMEOUT,
diff --git 
a/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/FetchSFTPTest.java
 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/FetchSFTPTest.java
new file mode 100644
index 00000000000..1795ea410be
--- /dev/null
+++ 
b/nifi-extension-bundles/nifi-standard-bundle/nifi-standard-processors/src/test/java/org/apache/nifi/processors/standard/FetchSFTPTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.nifi.processors.standard;
+
+import org.apache.nifi.processor.util.file.transfer.FetchFileTransfer;
+import org.apache.nifi.processors.standard.util.SFTPTransfer;
+import org.apache.nifi.processors.standard.util.SSHTestServer;
+import org.apache.nifi.util.TestRunner;
+import org.apache.nifi.util.TestRunners;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class FetchSFTPTest {
+
+    private static final String SOURCE_FILENAME = "test.txt";
+    private static final String SOURCE_CONTENTS = "source content";
+    private static final String DESTINATION_DIR = "/completed";
+
+    private SSHTestServer sshTestServer;
+    private TestRunner runner;
+    private Path serverRootPath;
+
+    @BeforeEach
+    void setup(@TempDir final Path rootPath) throws IOException {
+        sshTestServer = new SSHTestServer(rootPath);
+        sshTestServer.startServer();
+        serverRootPath = rootPath;
+
+        runner = TestRunners.newTestRunner(FetchSFTP.class);
+        runner.setProperty(FetchFileTransfer.HOSTNAME, 
sshTestServer.getHost());
+        runner.setProperty(FetchFileTransfer.UNDEFAULTED_PORT, 
Integer.toString(sshTestServer.getSSHPort()));
+        runner.setProperty(SFTPTransfer.USERNAME, sshTestServer.getUsername());
+        runner.setProperty(SFTPTransfer.PASSWORD, sshTestServer.getPassword());
+        runner.setProperty(SFTPTransfer.USE_KEEPALIVE_ON_TIMEOUT, 
Boolean.FALSE.toString());
+        runner.setProperty(SFTPTransfer.STRICT_HOST_KEY_CHECKING, 
Boolean.FALSE.toString());
+        runner.setProperty(SFTPTransfer.DATA_TIMEOUT, "30 sec");
+    }
+
+    @AfterEach
+    void stopServer() throws IOException {
+        sshTestServer.stopServer();
+    }
+
+    @Test
+    void testMoveCompletionStrategyCleansUpSourceFile() throws IOException {
+        final Path sourceDir = 
Files.createDirectory(serverRootPath.resolve("source"));
+        final Path sourceFile = sourceDir.resolve(SOURCE_FILENAME);
+        Files.writeString(sourceFile, SOURCE_CONTENTS, StandardCharsets.UTF_8);
+
+        final Path completedDir = 
Files.createDirectory(serverRootPath.resolve("completed"));
+
+        runner.setProperty(FetchFileTransfer.REMOTE_FILENAME, "/source/" + 
SOURCE_FILENAME);
+        runner.setProperty(FetchFileTransfer.COMPLETION_STRATEGY, 
FetchFileTransfer.COMPLETION_MOVE.getValue());
+        runner.setProperty(FetchFileTransfer.MOVE_DESTINATION_DIR, 
DESTINATION_DIR);
+        runner.setProperty(FetchFileTransfer.MOVE_CREATE_DIRECTORY, 
Boolean.FALSE.toString());
+
+        runner.enqueue(new byte[0]);
+        runner.run();
+
+        runner.assertTransferCount(FetchFileTransfer.REL_SUCCESS, 1);
+        assertFalse(Files.exists(sourceFile), "Source file should have been 
moved");
+        assertTrue(Files.exists(completedDir.resolve(SOURCE_FILENAME)), "File 
should exist in destination directory");
+    }
+
+    @Test
+    void testMoveCompletionStrategyOverwritesExistingDestinationFile() throws 
IOException {
+        // When conflict resolution is set to Replace, FetchSFTP should 
proactively delete the destination
+        // before renaming. This mirrors the behaviour needed for SFTP servers 
(e.g. OpenSSH) that return
+        // SSH_FX_FAILURE on rename when the destination already exists.
+        final Path sourceDir = 
Files.createDirectory(serverRootPath.resolve("source"));
+        final Path sourceFile = sourceDir.resolve(SOURCE_FILENAME);
+        Files.writeString(sourceFile, SOURCE_CONTENTS, StandardCharsets.UTF_8);
+
+        final Path completedDir = 
Files.createDirectory(serverRootPath.resolve("completed"));
+        final Path existingDestination = completedDir.resolve(SOURCE_FILENAME);
+        Files.writeString(existingDestination, "old content", 
StandardCharsets.UTF_8);
+
+        runner.setProperty(FetchFileTransfer.REMOTE_FILENAME, "/source/" + 
SOURCE_FILENAME);
+        runner.setProperty(FetchFileTransfer.COMPLETION_STRATEGY, 
FetchFileTransfer.COMPLETION_MOVE.getValue());
+        runner.setProperty(FetchFileTransfer.MOVE_DESTINATION_DIR, 
DESTINATION_DIR);
+        runner.setProperty(FetchFileTransfer.MOVE_CREATE_DIRECTORY, 
Boolean.FALSE.toString());
+        runner.setProperty(FetchFileTransfer.MOVE_CONFLICT_RESOLUTION, 
FetchFileTransfer.MoveConflictResolution.REPLACE.getValue());
+
+        runner.enqueue(new byte[0]);
+        runner.run();
+
+        runner.assertTransferCount(FetchFileTransfer.REL_SUCCESS, 1);
+        assertFalse(Files.exists(sourceFile), "Source file should have been 
moved");
+        assertTrue(Files.exists(existingDestination), "Destination file should 
exist after move");
+    }
+}

Reply via email to