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

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


The following commit(s) were added to refs/heads/main by this push:
     new c19093c9127 IGNITE-27548 Add recursive option to deploy unit command 
(#7402)
c19093c9127 is described below

commit c19093c9127dd3c8fdef80fdb3f94d99f3b40688
Author: Mikhail <[email protected]>
AuthorDate: Wed Jan 14 13:31:26 2026 +0300

    IGNITE-27548 Add recursive option to deploy unit command (#7402)
---
 .../cli/call/unit/ItDeployUndeployCallsTest.java   | 84 +++++++++++++++++++
 .../cli/commands/unit/ItDeploymentUnitTest.java    | 60 ++++++++++++++
 .../cli/call/cluster/unit/DeployUnitCall.java      | 41 +++++----
 .../cli/call/cluster/unit/DeployUnitCallInput.java | 18 +++-
 .../cli/call/cluster/unit/DeployUnitClient.java    | 44 +++++++++-
 .../cli/call/cluster/unit/DeploymentContent.java   | 49 +++++++++++
 .../call/cluster/unit/FilesDeploymentContent.java  | 73 ++++++++++++++++
 .../call/cluster/unit/ZipDeploymentContent.java    | 96 ++++++++++++++++++++++
 .../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 ++++++++++
 13 files changed, 510 insertions(+), 24 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..e54181a7a50 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,86 @@ 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));
+        });
+
+        // Cleanup
+        CallOutput<String> undeployOutput = 
undeployUnitCall.execute(undeployInput("recursive.test.id", "1.0.0"));
+        assertThat(undeployOutput.hasError()).isFalse();
+        await().untilAsserted(() -> 
assertThat(listUnitCall.execute(listIdInput("recursive.test.id")).isEmpty()).isTrue());
+    }
+
+    @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));
+        });
+
+        // Cleanup
+        CallOutput<String> undeployOutput = 
undeployUnitCall.execute(undeployInput("deep.recursive.test.id", "1.0.0"));
+        assertThat(undeployOutput.hasError()).isFalse();
+        await().untilAsserted(() -> 
assertThat(listUnitCall.execute(listIdInput("deep.recursive.test.id")).isEmpty()).isTrue());
+    }
 }
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..d9fada0a159 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,66 @@ 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");
+        });
+    }
+
     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..19d5b70f148 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
@@ -18,16 +18,14 @@
 package org.apache.ignite.internal.cli.call.cluster.unit;
 
 import static java.util.concurrent.CompletableFuture.completedFuture;
+import static 
org.apache.ignite.internal.cli.core.call.DefaultCallOutput.failure;
 
-import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 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 okhttp3.Call;
 import org.apache.ignite.internal.cli.core.call.AsyncCall;
 import org.apache.ignite.internal.cli.core.call.CallOutput;
@@ -62,32 +60,41 @@ public class DeployUnitCall implements 
AsyncCall<DeployUnitCallInput, String> {
 
         Path path = input.path();
         if (Files.notExists(path)) {
-            return completedFuture(DefaultCallOutput.failure(new 
FileNotFoundException(path.toString())));
+            return completedFuture(failure(new 
FileNotFoundException(path.toString())));
         }
-        List<File> files;
-        try (Stream<Path> stream = Files.walk(path, 1)) {
-            files = stream
-                    .filter(Files::isRegularFile)
-                    .map(Path::toFile)
-                    .collect(Collectors.toList());
+
+        try {
+            DeploymentContent content = input.recursive()
+                    ? ZipDeploymentContent.fromDirectory(path)
+                    : FilesDeploymentContent.fromPath(path);
+            return executeDeploy(input, api, content);
         } catch (IOException e) {
-            return completedFuture(DefaultCallOutput.failure(e));
+            return completedFuture(failure(e));
         }
+    }
 
+    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);
+                return failure(e);
+            } finally {
+                content.cleanup();
             }
             if (call.isCanceled()) {
-                return DefaultCallOutput.failure(new RuntimeException("Unit 
deployment process was canceled"));
+                return failure(new RuntimeException("Unit deployment process 
was canceled"));
             } else if (callback.exception() != null) {
                 return handleException(callback.exception(), input);
             } else {
@@ -102,13 +109,13 @@ public class DeployUnitCall implements 
AsyncCall<DeployUnitCallInput, String> {
             if (apiException.getCode() == 409) {
                 // special case when cluster is not initialized
                 if (apiException.getResponseBody().contains("Cluster is not 
initialized")) {
-                    return DefaultCallOutput.failure(new 
IgniteCliApiException(exception, input.clusterUrl()));
+                    return failure(new IgniteCliApiException(exception, 
input.clusterUrl()));
                 }
-                return DefaultCallOutput.failure(new 
UnitAlreadyExistsException(input.id(), input.version()));
+                return failure(new UnitAlreadyExistsException(input.id(), 
input.version()));
             }
         }
 
-        return DefaultCallOutput.failure(new IgniteCliApiException(exception, 
input.clusterUrl()));
+        return failure(new IgniteCliApiException(exception, 
input.clusterUrl()));
     }
 
     @Nullable
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/call/cluster/unit/DeploymentContent.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeploymentContent.java
new file mode 100644
index 00000000000..3bb9f74145d
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/DeploymentContent.java
@@ -0,0 +1,49 @@
+/*
+ * 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.ignite.internal.cli.call.cluster.unit;
+
+import java.util.List;
+import okhttp3.Call;
+import org.apache.ignite.rest.client.model.DeployMode;
+import org.jetbrains.annotations.Nullable;
+
+/** Abstraction for deployment content that can be either files or a ZIP 
archive. */
+interface DeploymentContent {
+    /**
+     * Executes the deployment and returns the HTTP call.
+     *
+     * @param api The deployment client.
+     * @param unitId The unit ID.
+     * @param version The unit version.
+     * @param deployMode The deployment mode.
+     * @param initialNodes The initial nodes to deploy to.
+     * @param callback The callback for tracking progress.
+     * @return 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();
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/FilesDeploymentContent.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/FilesDeploymentContent.java
new file mode 100644
index 00000000000..e2a1888d45c
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/FilesDeploymentContent.java
@@ -0,0 +1,73 @@
+/*
+ * 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.ignite.internal.cli.call.cluster.unit;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+import okhttp3.Call;
+import org.apache.ignite.rest.client.model.DeployMode;
+import org.jetbrains.annotations.Nullable;
+
+/** Deployment content for regular files (non-recursive). */
+class FilesDeploymentContent implements DeploymentContent {
+    private final List<File> files;
+
+    private FilesDeploymentContent(List<File> files) {
+        this.files = files;
+    }
+
+    /**
+     * Creates a deployment content from files in the given path 
(non-recursive, depth 1).
+     *
+     * @param path The path to collect files from.
+     * @return The deployment content.
+     * @throws IOException If an I/O error occurs.
+     */
+    static FilesDeploymentContent fromPath(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());
+        }
+        return new FilesDeploymentContent(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
+    }
+}
diff --git 
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
new file mode 100644
index 00000000000..c1be70f23ab
--- /dev/null
+++ 
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/cluster/unit/ZipDeploymentContent.java
@@ -0,0 +1,96 @@
+/*
+ * 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.ignite.internal.cli.call.cluster.unit;
+
+import static org.apache.ignite.internal.util.ExceptionUtils.sneakyThrow;
+
+import java.io.File;
+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.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+import okhttp3.Call;
+import org.apache.ignite.rest.client.model.DeployMode;
+import org.jetbrains.annotations.Nullable;
+
+/** Deployment content for ZIP archive (recursive directory deployment). */
+class ZipDeploymentContent implements DeploymentContent {
+    private final File zipFile;
+
+    private ZipDeploymentContent(File zipFile) {
+        this.zipFile = zipFile;
+    }
+
+    /**
+     * Creates a deployment content by zipping the given directory recursively.
+     *
+     * @param sourceDir The directory to zip.
+     * @return The deployment content.
+     * @throws IOException If an I/O error occurs.
+     */
+    static ZipDeploymentContent fromDirectory(Path sourceDir) throws 
IOException {
+        File zipFile = createZipFromDirectory(sourceDir);
+        return new ZipDeploymentContent(zipFile);
+    }
+
+    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 sneakyThrow(e);
+                    }
+                });
+            }
+        }
+        return zipPath.toFile();
+    }
+
+    @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() {
+        try {
+            Files.deleteIfExists(zipFile.toPath());
+        } catch (IOException ignored) {
+            // Best effort cleanup - temporary file will be deleted on JVM exit
+        }
+    }
+}
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