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 {