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

mpochatkin pushed a commit to branch IGNITE-27548
in repository https://gitbox.apache.org/repos/asf/ignite-3.git

commit 20ac0ff8c74aa0124bddf69ad89ff59de1260540
Author: Pochatkin Mikhail <[email protected]>
AuthorDate: Tue Jan 13 17:44:42 2026 +0300

    IGNITE-27548 Add recursive option to deploy unit command
---
 .../cli/call/unit/ItDeployUndeployCallsTest.java   |  74 +++++++++++++
 .../cli/commands/unit/ItDeploymentUnitTest.java    |  74 +++++++++++++
 .../cli/call/cluster/unit/DeployUnitCall.java      | 123 ++++++++++++++++++++-
 .../cli/call/cluster/unit/DeployUnitCallInput.java |  18 ++-
 .../cli/call/cluster/unit/DeployUnitClient.java    |  44 +++++++-
 .../ignite/internal/cli/commands/Options.java      |   6 +
 .../cluster/unit/ClusterUnitDeployCommand.java     |   2 +-
 .../cluster/unit/ClusterUnitDeployReplCommand.java |   2 +-
 .../cluster/unit/UnitDeployOptionsMixin.java       |  14 +++
 .../cli/commands/unit/DeployCommandTest.java       |  45 ++++++++
 10 files changed, 392 insertions(+), 10 deletions(-)

diff --git 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java
 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java
index 569e063bf8b..603382c673c 100644
--- 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java
+++ 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/call/unit/ItDeployUndeployCallsTest.java
@@ -26,6 +26,8 @@ import static org.awaitility.Awaitility.await;
 
 import jakarta.inject.Inject;
 import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 import org.apache.ignite.internal.cli.CliIntegrationTest;
@@ -224,4 +226,76 @@ public class ItDeployUndeployCallsTest extends 
CliIntegrationTest {
                     .containsExactly((new 
UnitVersionStatus()).version("1.1.0").status(DEPLOYED));
         });
     }
+
+    @Test
+    @DisplayName("Should deploy unit recursively from directory with 
subdirectories")
+    void deployRecursive() throws IOException {
+        // Given a directory with subdirectories
+        Path recursiveDir = Files.createTempDirectory(WORK_DIR, "recursive");
+        Path subDir = Files.createDirectory(recursiveDir.resolve("subdir"));
+        Files.createFile(recursiveDir.resolve("root.txt"));
+        Files.createFile(subDir.resolve("nested.txt"));
+
+        DeployUnitCallInput input = DeployUnitCallInput.builder()
+                .id("recursive.test.id")
+                .version("1.0.0")
+                .path(recursiveDir)
+                .recursive(true)
+                .clusterUrl(NODE_URL)
+                .build();
+
+        // When deploy unit recursively
+        CallOutput<String> deployOutput = get(
+                deployUnitCallFactory.create(tracker()).execute(input)
+        );
+
+        // Then
+        assertThat(deployOutput.hasError()).isFalse();
+        
assertThat(deployOutput.body()).isEqualTo(MessageUiComponent.from(UiElements.done()).render());
+
+        await().untilAsserted(() -> {
+            // And list contains the deployed unit
+            List<UnitStatus> unitStatuses = 
listUnitCall.execute(listIdInput("recursive.test.id")).body();
+            assertThat(unitStatuses.size()).isEqualTo(1);
+            Assertions.assertThat(unitStatuses.get(0).getVersionToStatus())
+                    .containsExactly((new 
UnitVersionStatus()).version("1.0.0").status(DEPLOYED));
+        });
+    }
+
+    @Test
+    @DisplayName("Should deploy unit recursively from deeply nested directory")
+    void deployRecursiveDeepNesting() throws IOException {
+        // Given a directory with deeply nested subdirectories
+        Path deepDir = Files.createTempDirectory(WORK_DIR, "deep");
+        Path level1 = Files.createDirectory(deepDir.resolve("level1"));
+        Path level2 = Files.createDirectory(level1.resolve("level2"));
+        Files.createFile(deepDir.resolve("root.txt"));
+        Files.createFile(level1.resolve("file1.txt"));
+        Files.createFile(level2.resolve("file2.txt"));
+
+        DeployUnitCallInput input = DeployUnitCallInput.builder()
+                .id("deep.recursive.test.id")
+                .version("1.0.0")
+                .path(deepDir)
+                .recursive(true)
+                .clusterUrl(NODE_URL)
+                .build();
+
+        // When deploy unit recursively
+        CallOutput<String> deployOutput = get(
+                deployUnitCallFactory.create(tracker()).execute(input)
+        );
+
+        // Then
+        assertThat(deployOutput.hasError()).isFalse();
+        
assertThat(deployOutput.body()).isEqualTo(MessageUiComponent.from(UiElements.done()).render());
+
+        await().untilAsserted(() -> {
+            // And list contains the deployed unit
+            List<UnitStatus> unitStatuses = 
listUnitCall.execute(listIdInput("deep.recursive.test.id")).body();
+            assertThat(unitStatuses.size()).isEqualTo(1);
+            Assertions.assertThat(unitStatuses.get(0).getVersionToStatus())
+                    .containsExactly((new 
UnitVersionStatus()).version("1.0.0").status(DEPLOYED));
+        });
+    }
 }
diff --git 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java
 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java
index 277c1390f5e..06e806d0484 100644
--- 
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java
+++ 
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItDeploymentUnitTest.java
@@ -323,6 +323,80 @@ public class ItDeploymentUnitTest extends 
CliIntegrationTest {
         });
     }
 
+    @Test
+    @DisplayName("Should deploy a unit from directory with subdirectories 
using --recursive")
+    void deployRecursive() throws IOException {
+        // Given a directory with subdirectories
+        Path recursiveDir = 
Files.createDirectory(WORK_DIR.resolve("recursive-test"));
+        Path subDir = Files.createDirectory(recursiveDir.resolve("subdir"));
+        Files.createFile(recursiveDir.resolve("root-file.txt"));
+        Files.createFile(subDir.resolve("nested-file.txt"));
+
+        // When deploy with --recursive option
+        execute("cluster", "unit", "deploy", "test.unit.recursive.1", 
"--version", "1.0.0",
+                "--path", recursiveDir.toString(), "--recursive");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains("Done")
+        );
+
+        // And unit is deployed
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", 
"test.unit.recursive.1");
+
+            assertDeployed("test.unit.recursive.1");
+        });
+    }
+
+    @Test
+    @DisplayName("Should deploy a unit from deeply nested directory using 
--recursive")
+    void deployRecursiveDeepNesting() throws IOException {
+        // Given a directory with deeply nested subdirectories
+        Path deepDir = Files.createDirectory(WORK_DIR.resolve("deep-test"));
+        Path level1 = Files.createDirectory(deepDir.resolve("level1"));
+        Path level2 = Files.createDirectory(level1.resolve("level2"));
+        Path level3 = Files.createDirectory(level2.resolve("level3"));
+        Files.createFile(deepDir.resolve("root.txt"));
+        Files.createFile(level1.resolve("file1.txt"));
+        Files.createFile(level2.resolve("file2.txt"));
+        Files.createFile(level3.resolve("file3.txt"));
+
+        // When deploy with --recursive option
+        execute("cluster", "unit", "deploy", "test.unit.recursive.2", 
"--version", "1.0.0",
+                "--path", deepDir.toString(), "--recursive");
+
+        // Then
+        assertAll(
+                this::assertExitCodeIsZero,
+                this::assertErrOutputIsEmpty,
+                () -> assertOutputContains("Done")
+        );
+
+        // And unit is deployed
+        await().untilAsserted(() -> {
+            execute("cluster", "unit", "list", "--plain", 
"test.unit.recursive.2");
+
+            assertDeployed("test.unit.recursive.2");
+        });
+    }
+
+    @Test
+    @DisplayName("Should display error when --recursive is used with a file 
path")
+    void deployRecursiveWithFile() {
+        // When deploy with --recursive option pointing to a file
+        execute("cluster", "unit", "deploy", "test.unit.recursive.error", 
"--version", "1.0.0",
+                "--path", testFile, "--recursive");
+
+        // Then error is displayed
+        assertAll(
+                () -> assertExitCodeIs(2),
+                () -> assertErrOutputContains("The --recursive option requires 
a directory path")
+        );
+    }
+
     private void assertDeployed(String id) {
         assertDeployed(id, "*1.0.0");
     }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java
index 664adb8fc03..1079e9b8d70 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCall.java
@@ -22,12 +22,15 @@ import static 
java.util.concurrent.CompletableFuture.completedFuture;
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
+import java.io.OutputStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 import okhttp3.Call;
 import org.apache.ignite.internal.cli.core.call.AsyncCall;
 import org.apache.ignite.internal.cli.core.call.CallOutput;
@@ -64,27 +67,52 @@ public class DeployUnitCall implements 
AsyncCall<DeployUnitCallInput, String> {
         if (Files.notExists(path)) {
             return completedFuture(DefaultCallOutput.failure(new 
FileNotFoundException(path.toString())));
         }
+
+        DeploymentContent content;
+        try {
+            content = input.recursive() ? createZipContent(path) : 
createFilesContent(path);
+        } catch (IOException e) {
+            return completedFuture(DefaultCallOutput.failure(e));
+        }
+
+        return executeDeploy(input, api, content);
+    }
+
+    private static DeploymentContent createFilesContent(Path path) throws 
IOException {
         List<File> files;
         try (Stream<Path> stream = Files.walk(path, 1)) {
             files = stream
                     .filter(Files::isRegularFile)
                     .map(Path::toFile)
                     .collect(Collectors.toList());
-        } catch (IOException e) {
-            return completedFuture(DefaultCallOutput.failure(e));
         }
+        return new FilesDeploymentContent(files);
+    }
+
+    private static DeploymentContent createZipContent(Path path) throws 
IOException {
+        File zipFile = createZipFromDirectory(path);
+        return new ZipDeploymentContent(zipFile);
+    }
 
+    private CompletableFuture<CallOutput<String>> executeDeploy(
+            DeployUnitCallInput input,
+            DeployUnitClient api,
+            DeploymentContent content
+    ) {
         TrackingCallback<Boolean> callback = new TrackingCallback<>(tracker);
         String ver = input.version() == null ? "" : input.version();
         DeployMode deployMode = inferDeployMode(input.nodes());
         List<String> initialNodes = deployMode == null ? input.nodes() : null;
-        Call call = api.deployUnitAsync(input.id(), files, ver, deployMode, 
initialNodes, callback);
+
+        Call call = content.deploy(api, input.id(), ver, deployMode, 
initialNodes, callback);
 
         return CompletableFuture.supplyAsync(() -> {
             try {
                 callback.awaitDone();
             } catch (InterruptedException e) {
                 return DefaultCallOutput.failure(e);
+            } finally {
+                content.cleanup();
             }
             if (call.isCanceled()) {
                 return DefaultCallOutput.failure(new RuntimeException("Unit 
deployment process was canceled"));
@@ -96,6 +124,95 @@ public class DeployUnitCall implements 
AsyncCall<DeployUnitCallInput, String> {
         });
     }
 
+    private static File createZipFromDirectory(Path sourceDir) throws 
IOException {
+        Path zipPath = Files.createTempFile("deploy-unit-", ".zip");
+        try (OutputStream os = Files.newOutputStream(zipPath);
+                ZipOutputStream zos = new ZipOutputStream(os)) {
+            try (Stream<Path> stream = Files.walk(sourceDir)) {
+                stream.filter(Files::isRegularFile).forEach(filePath -> {
+                    Path relativePath = sourceDir.relativize(filePath);
+                    try {
+                        ZipEntry zipEntry = new 
ZipEntry(relativePath.toString().replace('\\', '/'));
+                        zos.putNextEntry(zipEntry);
+                        Files.copy(filePath, zos);
+                        zos.closeEntry();
+                    } catch (IOException e) {
+                        throw new RuntimeException("Failed to add file to ZIP: 
" + filePath, e);
+                    }
+                });
+            }
+        }
+        return zipPath.toFile();
+    }
+
+    /** Abstraction for deployment content that can be either files or a ZIP 
archive. */
+    private interface DeploymentContent {
+        /** Executes the deployment and returns the HTTP call. */
+        Call deploy(
+                DeployUnitClient api,
+                String unitId,
+                String version,
+                @Nullable DeployMode deployMode,
+                @Nullable List<String> initialNodes,
+                TrackingCallback<Boolean> callback
+        );
+
+        /** Performs cleanup after deployment (e.g., deletes temporary files). 
*/
+        void cleanup();
+    }
+
+    /** Deployment content for regular files (non-recursive). */
+    private static class FilesDeploymentContent implements DeploymentContent {
+        private final List<File> files;
+
+        FilesDeploymentContent(List<File> files) {
+            this.files = files;
+        }
+
+        @Override
+        public Call deploy(
+                DeployUnitClient api,
+                String unitId,
+                String version,
+                @Nullable DeployMode deployMode,
+                @Nullable List<String> initialNodes,
+                TrackingCallback<Boolean> callback
+        ) {
+            return api.deployUnitAsync(unitId, files, version, deployMode, 
initialNodes, callback);
+        }
+
+        @Override
+        public void cleanup() {
+            // No cleanup needed for regular files
+        }
+    }
+
+    /** Deployment content for ZIP archive (recursive). */
+    private static class ZipDeploymentContent implements DeploymentContent {
+        private final File zipFile;
+
+        ZipDeploymentContent(File zipFile) {
+            this.zipFile = zipFile;
+        }
+
+        @Override
+        public Call deploy(
+                DeployUnitClient api,
+                String unitId,
+                String version,
+                @Nullable DeployMode deployMode,
+                @Nullable List<String> initialNodes,
+                TrackingCallback<Boolean> callback
+        ) {
+            return api.deployZipUnitAsync(unitId, zipFile, version, 
deployMode, initialNodes, callback);
+        }
+
+        @Override
+        public void cleanup() {
+            zipFile.delete();
+        }
+    }
+
     private static CallOutput<String> handleException(Exception exception, 
DeployUnitCallInput input) {
         if (exception instanceof ApiException) {
             ApiException apiException = (ApiException) exception;
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java
index f22b94c3155..6bef7760423 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitCallInput.java
@@ -32,13 +32,16 @@ public class DeployUnitCallInput implements CallInput {
 
     private final List<String> nodes;
 
+    private final boolean recursive;
+
     private final String clusterUrl;
 
-    private DeployUnitCallInput(String id, String version, Path path, 
List<String> nodes, String clusterUrl) {
+    private DeployUnitCallInput(String id, String version, Path path, 
List<String> nodes, boolean recursive, String clusterUrl) {
         this.id = id;
         this.version = version;
         this.path = path;
         this.nodes = nodes;
+        this.recursive = recursive;
         this.clusterUrl = clusterUrl;
     }
 
@@ -62,6 +65,10 @@ public class DeployUnitCallInput implements CallInput {
         return nodes;
     }
 
+    public boolean recursive() {
+        return recursive;
+    }
+
     public String clusterUrl() {
         return clusterUrl;
     }
@@ -76,6 +83,8 @@ public class DeployUnitCallInput implements CallInput {
 
         private List<String> nodes;
 
+        private boolean recursive;
+
         private String clusterUrl;
 
         public DeployUnitCallBuilder id(String id) {
@@ -98,13 +107,18 @@ public class DeployUnitCallInput implements CallInput {
             return this;
         }
 
+        public DeployUnitCallBuilder recursive(boolean recursive) {
+            this.recursive = recursive;
+            return this;
+        }
+
         public DeployUnitCallBuilder clusterUrl(String clusterUrl) {
             this.clusterUrl = clusterUrl;
             return this;
         }
 
         public DeployUnitCallInput build() {
-            return new DeployUnitCallInput(id, version, path, nodes, 
clusterUrl);
+            return new DeployUnitCallInput(id, version, path, nodes, 
recursive, clusterUrl);
         }
     }
 }
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java
index e390e211545..f9766d12ab3 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeployUnitClient.java
@@ -95,11 +95,49 @@ public class DeployUnitClient {
             @Nullable DeployMode deployMode,
             @Nullable List<String> initialNodes,
             ApiCallback<Boolean> callback
+    ) {
+        return buildDeployCall(unitId, unitContent, unitVersion, deployMode, 
initialNodes, callback, false);
+    }
+
+    /**
+     * Deploy unit from ZIP file asynchronously.
+     *
+     * @param unitId The ID of the deployment unit.
+     * @param zipFile The ZIP file to deploy.
+     * @param unitVersion The version of the deployment unit.
+     * @param deployMode The deployment mode.
+     * @param initialNodes The initial set of nodes where unit will be 
deployed.
+     * @param callback The callback for tracking progress.
+     * @return Request call.
+     */
+    public Call deployZipUnitAsync(
+            String unitId,
+            File zipFile,
+            String unitVersion,
+            @Nullable DeployMode deployMode,
+            @Nullable List<String> initialNodes,
+            ApiCallback<Boolean> callback
+    ) {
+        Call call = buildDeployCall(unitId, List.of(zipFile), unitVersion, 
deployMode, initialNodes, callback, true);
+        apiClient.executeAsync(call, Boolean.class, callback);
+        return call;
+    }
+
+    private Call buildDeployCall(
+            String unitId,
+            List<File> unitContent,
+            String unitVersion,
+            @Nullable DeployMode deployMode,
+            @Nullable List<String> initialNodes,
+            ApiCallback<Boolean> callback,
+            boolean isZip
     ) {
         StringBuilder url = new StringBuilder(apiClient.getBasePath());
-        url
-                .append("/management/v1/deployment/units")
-                .append('/').append(apiClient.escapeString(unitId))
+        url.append("/management/v1/deployment/units");
+        if (isZip) {
+            url.append("/zip");
+        }
+        url.append('/').append(apiClient.escapeString(unitId))
                 .append('/').append(apiClient.escapeString(unitVersion));
 
         List<Pair> queryParams = new ArrayList<>();
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java
index 6fc06452a33..8bd07db2c07 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/Options.java
@@ -247,6 +247,12 @@ public enum Options {
         /** Unit nodes option description. */
         public static final String UNIT_NODES_OPTION_DESC = "Initial set of 
nodes where the unit will be deployed";
 
+        /** Unit recursive option long name. */
+        public static final String UNIT_RECURSIVE_OPTION = "--recursive";
+
+        /** Unit recursive option description. */
+        public static final String UNIT_RECURSIVE_OPTION_DESC = "Deploy 
directory recursively (creates a ZIP file and uses ZIP deployment)";
+
         public static final String CLUSTER_CONFIG_OPTION = "--config";
 
         public static final String CLUSTER_CONFIG_OPTION_DESC = "Cluster 
configuration that "
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java
index 0ae4f30a5e8..e59cb1b9867 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployCommand.java
@@ -29,7 +29,7 @@ import picocli.CommandLine.Command;
 import picocli.CommandLine.Mixin;
 
 /** Command to deploy a unit. */
-@Command(name = "deploy", description = "Deploys a unit from file or a 
directory (non-recursively)")
+@Command(name = "deploy", description = "Deploys a unit from file or a 
directory (use --recursive for subdirectories)")
 public class ClusterUnitDeployCommand extends BaseCommand implements 
Callable<Integer> {
 
     @Mixin
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java
index f2e633a45a1..c22b1f4fba4 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/ClusterUnitDeployReplCommand.java
@@ -29,7 +29,7 @@ import picocli.CommandLine.Command;
 import picocli.CommandLine.Mixin;
 
 /** Command to deploy a unit in REPL mode. */
-@Command(name = "deploy", description = "Deploys a unit")
+@Command(name = "deploy", description = "Deploys a unit from file or a 
directory (use --recursive for subdirectories)")
 public class ClusterUnitDeployReplCommand extends BaseCommand implements 
Runnable {
 
     @Mixin
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java
index 79198f01fb9..77a21599a2f 100644
--- 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/cluster/unit/UnitDeployOptionsMixin.java
@@ -21,6 +21,8 @@ import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_NOD
 import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_NODES_OPTION_DESC;
 import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_PATH_OPTION;
 import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_PATH_OPTION_DESC;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_RECURSIVE_OPTION;
+import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_RECURSIVE_OPTION_DESC;
 import static 
org.apache.ignite.internal.cli.commands.Options.Constants.UNIT_VERSION_OPTION_DESC;
 import static 
org.apache.ignite.internal.cli.commands.Options.Constants.VERSION_OPTION;
 
@@ -92,12 +94,24 @@ class UnitDeployOptionsMixin {
         nodes = values;
     }
 
+    /** Recursive deployment flag. */
+    @Option(names = UNIT_RECURSIVE_OPTION, description = 
UNIT_RECURSIVE_OPTION_DESC)
+    private boolean recursive;
+
     DeployUnitCallInput toDeployUnitCallInput(String url) {
+        if (recursive && !Files.isDirectory(path)) {
+            throw new ParameterException(
+                    spec.commandLine(),
+                    "The --recursive option requires a directory path, but '" 
+ path + "' is not a directory"
+            );
+        }
+
         return DeployUnitCallInput.builder()
                 .id(id)
                 .version(version)
                 .path(path)
                 .nodes(nodes)
+                .recursive(recursive)
                 .clusterUrl(url)
                 .build();
     }
diff --git 
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java
 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java
index d3d11f7956d..d45093a1ce3 100644
--- 
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java
+++ 
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/commands/unit/DeployCommandTest.java
@@ -40,6 +40,51 @@ class DeployCommandTest extends CliCommandTestBase {
         return ClusterUnitDeployCommand.class;
     }
 
+    @Test
+    @DisplayName("Should display error when --recursive is used with a file 
path")
+    void recursiveWithFilePath(@WorkDirectory Path workDir) throws IOException 
{
+        Path testFile = Files.createFile(workDir.resolve("test.txt"));
+
+        // When executed with --recursive option pointing to a file
+        execute("--path", testFile.toString(), "--version", "1.0.0", 
"--recursive", "id");
+
+        // Error is printed
+        assertAll(
+                () -> assertExitCodeIs(2),
+                this::assertOutputIsEmpty,
+                () -> assertErrOutputContains("The --recursive option requires 
a directory path")
+        );
+    }
+
+    @Test
+    @DisplayName("Should accept --recursive option with a directory path 
(passes validation)")
+    void recursiveWithDirectoryPath(@WorkDirectory Path workDir) throws 
IOException {
+        Path testDir = Files.createDirectory(workDir.resolve("testDir"));
+        Files.createFile(testDir.resolve("test.txt"));
+
+        // When executed with --recursive option pointing to a directory
+        // Note: This will fail at execution stage (no server), but should 
pass option validation
+        execute("--path", testDir.toString(), "--version", "1.0.0", 
"--recursive", "id");
+
+        // Option validation passes - exit code 1 means execution error (no 
server),
+        // not exit code 2 which would mean parameter validation error
+        assertExitCodeIsError(); // Exit code 1, not 2
+    }
+
+    @Test
+    @DisplayName("Should display error when path does not exist")
+    void pathDoesNotExist(@WorkDirectory Path workDir) {
+        // When executed with non-existent path
+        execute("--path", workDir.resolve("nonexistent").toString(), 
"--version", "1.0.0", "id");
+
+        // Error is printed
+        assertAll(
+                () -> assertExitCodeIs(2),
+                this::assertOutputIsEmpty,
+                () -> assertErrOutputContains("No such file or directory")
+        );
+    }
+
     @Test
     @DisplayName("Aliases couldn't be used with explicit nodes list")
     void aliasesWithExplicitNodesList(@WorkDirectory Path workDir) throws 
IOException {

Reply via email to