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 95f9aeb7393 IGNITE-27201 Add CLI command to inspect deployment unit
file tree (#7356)
95f9aeb7393 is described below
commit 95f9aeb7393800ceb4951d108151edb114f13aed
Author: Mikhail <[email protected]>
AuthorDate: Thu Jan 15 18:17:11 2026 +0300
IGNITE-27201 Add CLI command to inspect deployment unit file tree (#7356)
---
.../unit/ItNodeUnitInspectCommandTest.java | 387 +++++++++++++++++++++
.../unit/ItNodeUnitInspectReplCommandTest.java} | 25 +-
.../call/cluster/unit/ZipDeploymentContent.java | 20 +-
.../cli/call/node/unit/NodeUnitInspectCall.java | 50 +++
.../cli/call/unit/UnitInspectCallInput.java | 80 +++++
.../cli/commands/node/unit/NodeUnitCommand.java | 2 +-
.../commands/node/unit/NodeUnitInspectCommand.java | 70 ++++
.../node/unit/NodeUnitInspectReplCommand.java | 74 ++++
.../commands/node/unit/NodeUnitReplCommand.java | 4 +-
.../cli/decorators/UnitInspectDecorator.java | 121 +++++++
.../call/node/unit/NodeUnitInspectCallTest.java | 161 +++++++++
.../cli/decorators/UnitInspectDecoratorTest.java | 179 ++++++++++
modules/rest-api/build.gradle | 2 +
.../internal/rest/api/deployment/UnitEntry.java | 49 ++-
14 files changed, 1206 insertions(+), 18 deletions(-)
diff --git
a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
new file mode 100644
index 00000000000..f0021e5daf9
--- /dev/null
+++
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectCommandTest.java
@@ -0,0 +1,387 @@
+/*
+ * 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.commands.unit;
+
+import static org.awaitility.Awaitility.await;
+import static org.junit.jupiter.api.Assertions.assertAll;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.ignite.internal.cli.CliIntegrationTest;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/** Integration test for node unit inspect command. */
+public class ItNodeUnitInspectCommandTest extends CliIntegrationTest {
+ private static String testFile;
+
+ private static Path testDirectory;
+
+ private static Path recursiveDirectory;
+
+ private static Path emptyFolderDirectory;
+
+ @BeforeAll
+ static void beforeAll() throws IOException {
+ testDirectory =
Files.createDirectory(WORK_DIR.resolve("test-structure"));
+ testFile =
Files.createFile(testDirectory.resolve("test.txt")).toString();
+ Files.createFile(testDirectory.resolve("test2.txt"));
+ Files.createFile(testDirectory.resolve("test3.txt"));
+
+ // Files with special characters
+ Files.createFile(testDirectory.resolve("file with spaces.txt"));
+ Files.createFile(testDirectory.resolve("file-with-dashes.txt"));
+ Files.createFile(testDirectory.resolve("file_with_underscores.txt"));
+ Files.createFile(testDirectory.resolve("file.multiple.dots.txt"));
+ Files.createFile(testDirectory.resolve("file(with)parentheses.txt"));
+ Files.createFile(testDirectory.resolve("UPPERCASE.TXT"));
+ Files.createFile(testDirectory.resolve("MixedCase.Txt"));
+
+ // Subdirectory with files
+ Path subDir =
Files.createDirectory(testDirectory.resolve("sub-directory"));
+ Files.createFile(subDir.resolve("nested file.txt"));
+ Files.createFile(subDir.resolve("nested-file.txt"));
+
+ // Deeply nested directory
+ Path deepDir =
Files.createDirectories(testDirectory.resolve("level1").resolve("level2"));
+ Files.createFile(deepDir.resolve("deep file.txt"));
+
+ // Setup for recursive deployment test with 3-level structure
+ recursiveDirectory =
Files.createDirectory(WORK_DIR.resolve("test-recursive"));
+
+ // Level 1 - root files with different naming conventions
+ Files.createFile(recursiveDirectory.resolve("root-config.xml"));
+ Files.createFile(recursiveDirectory.resolve("ROOT_README.md"));
+ Files.createFile(recursiveDirectory.resolve("root file.txt"));
+
+ // Level 2 - subdirectories with different naming styles
+ Path srcDir = Files.createDirectory(recursiveDirectory.resolve("src"));
+ Files.createFile(srcDir.resolve("Main.java"));
+ Files.createFile(srcDir.resolve("Helper Class.java"));
+ Files.createFile(srcDir.resolve("util_functions.py"));
+
+ Path resourcesDir =
Files.createDirectory(recursiveDirectory.resolve("resources-dir"));
+ Files.createFile(resourcesDir.resolve("application.properties"));
+ Files.createFile(resourcesDir.resolve("messages_en.properties"));
+
+ Path libDir =
Files.createDirectory(recursiveDirectory.resolve("lib_folder"));
+ Files.createFile(libDir.resolve("dependency-1.0.jar"));
+ Files.createFile(libDir.resolve("NATIVE LIB.so"));
+
+ // Level 3 - deeply nested with various cases
+ Path srcSubDir =
Files.createDirectory(srcDir.resolve("com.example.app"));
+ Files.createFile(srcSubDir.resolve("Application.java"));
+ Files.createFile(srcSubDir.resolve("Service Impl.java"));
+ Files.createFile(srcSubDir.resolve("data_model.java"));
+
+ Path resourceSubDir =
Files.createDirectory(resourcesDir.resolve("i18n"));
+ Files.createFile(resourceSubDir.resolve("messages_ru.properties"));
+ Files.createFile(resourceSubDir.resolve("MESSAGES_DE.properties"));
+
+ Path libSubDir = Files.createDirectory(libDir.resolve("native-libs"));
+ Files.createFile(libSubDir.resolve("lib_native.dll"));
+ Files.createFile(libSubDir.resolve("Native Lib.dylib"));
+
+ // Setup for empty folder test
+ emptyFolderDirectory =
Files.createDirectory(WORK_DIR.resolve("test-empty-folder"));
+ Files.createFile(emptyFolderDirectory.resolve("existing-file.txt"));
+ Files.createDirectory(emptyFolderDirectory.resolve("empty-folder"));
+ Path nonEmptyFolder =
Files.createDirectory(emptyFolderDirectory.resolve("non-empty-folder"));
+ Files.createFile(nonEmptyFolder.resolve("file-in-folder.txt"));
+ Files.createDirectory(nonEmptyFolder.resolve("nested-empty-folder"));
+ }
+
+ @Test
+ @DisplayName("Should display unit structure with tree view")
+ void structureTreeView() {
+ // Given deployed unit with recursive option to include subdirectories
+ String id = "test.structure.unit.1";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", testDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure
+ execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+ // Then
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ () -> assertOutputContains(id),
+ () -> assertOutputContains("test.txt"),
+ () -> assertOutputContains("test2.txt"),
+ () -> assertOutputContains("test3.txt"),
+ () -> assertOutputContains("file with spaces.txt"),
+ () -> assertOutputContains("file-with-dashes.txt"),
+ () -> assertOutputContains("file_with_underscores.txt"),
+ () -> assertOutputContains("file.multiple.dots.txt"),
+ () -> assertOutputContains("file(with)parentheses.txt"),
+ () -> assertOutputContains("UPPERCASE.TXT"),
+ () -> assertOutputContains("MixedCase.Txt"),
+ () -> assertOutputContains("sub-directory"),
+ () -> assertOutputContains("nested file.txt"),
+ () -> assertOutputContains("nested-file.txt"),
+ () -> assertOutputContains("level1"),
+ () -> assertOutputContains("level2"),
+ () -> assertOutputContains("deep file.txt"),
+ () -> assertOutputContains(" B)") // File size display
+ );
+ }
+
+ @Test
+ @DisplayName("Should display unit structure with plain view")
+ void structurePlainView() {
+ // Given deployed unit with recursive option to include subdirectories
+ String id = "test.structure.unit.2";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", testDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure with plain option
+ execute("node", "unit", "inspect", id, "--version", "1.0.0",
"--plain");
+
+ // Then
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ () -> assertOutputContains("test.txt"),
+ () -> assertOutputContains("test2.txt"),
+ () -> assertOutputContains("test3.txt"),
+ () -> assertOutputContains("file with spaces.txt"),
+ () -> assertOutputContains("file-with-dashes.txt"),
+ () -> assertOutputContains("file_with_underscores.txt"),
+ () -> assertOutputContains("file.multiple.dots.txt"),
+ () -> assertOutputContains("file(with)parentheses.txt"),
+ () -> assertOutputContains("sub-directory"),
+ () -> assertOutputContains("level1"),
+ () -> assertOutputContains("level2")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display error when version is missing")
+ void structureVersionIsMandatory() {
+ // When get structure without version
+ execute("node", "unit", "inspect", "test.unit.id");
+
+ // Then
+ assertAll(
+ () -> assertExitCodeIs(2),
+ () -> assertErrOutputContains("Missing required option:
'--version=<version>'"),
+ this::assertOutputIsEmpty
+ );
+ }
+
+ @Test
+ @DisplayName("Should display error when unit does not exist")
+ void structureUnitNotFound() {
+ // When get structure of non-existing unit
+ execute("node", "unit", "inspect", "non.existing.unit", "--version",
"1.0.0");
+
+ // Then
+ assertAll(
+ this::assertExitCodeIsError,
+ () -> assertErrOutputContains("not found")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display structure from file deployment")
+ void structureFromFile() {
+ // Given deployed unit from file
+ String id = "test.structure.unit.3";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", testFile);
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure
+ execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+ // Then
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ () -> assertOutputContains(id),
+ () -> assertOutputContains("test.txt")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display file sizes in human readable format")
+ void structureWithFileSizes() {
+ // Given deployed unit
+ String id = "test.structure.unit.4";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", testDirectory.toString());
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure
+ execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+ // Then verify file sizes are displayed
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ () -> assertOutputContains(" B)") // Size in bytes
+ );
+ }
+
+ @Test
+ @DisplayName("Should display 3-level structure with recursive deployment")
+ void structureWithRecursiveDeployment() {
+ // Given deployed unit with recursive option
+ String id = "test.recursive.unit.1";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", recursiveDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure
+ execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+ // Then verify all 3 levels are present with different naming cases
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ // Level 1 - root files
+ () -> assertOutputContains("root-config.xml"),
+ () -> assertOutputContains("ROOT_README.md"),
+ () -> assertOutputContains("root file.txt"),
+ // Level 2 - directories and their files
+ () -> assertOutputContains("src"),
+ () -> assertOutputContains("Main.java"),
+ () -> assertOutputContains("Helper Class.java"),
+ () -> assertOutputContains("util_functions.py"),
+ () -> assertOutputContains("resources-dir"),
+ () -> assertOutputContains("application.properties"),
+ () -> assertOutputContains("messages_en.properties"),
+ () -> assertOutputContains("lib_folder"),
+ () -> assertOutputContains("dependency-1.0.jar"),
+ () -> assertOutputContains("NATIVE LIB.so"),
+ // Level 3 - deeply nested directories and files
+ () -> assertOutputContains("com.example.app"),
+ () -> assertOutputContains("Application.java"),
+ () -> assertOutputContains("Service Impl.java"),
+ () -> assertOutputContains("data_model.java"),
+ () -> assertOutputContains("i18n"),
+ () -> assertOutputContains("messages_ru.properties"),
+ () -> assertOutputContains("MESSAGES_DE.properties"),
+ () -> assertOutputContains("native-libs"),
+ () -> assertOutputContains("lib_native.dll"),
+ () -> assertOutputContains("Native Lib.dylib")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display 3-level structure with recursive deployment
in plain view")
+ void structureWithRecursiveDeploymentPlainView() {
+ // Given deployed unit with recursive option
+ String id = "test.recursive.unit.2";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", recursiveDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure with plain view
+ execute("node", "unit", "inspect", id, "--version", "1.0.0",
"--plain");
+
+ // Then verify nested paths are displayed correctly
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ // Verify nested paths include parent directories
+ () -> assertOutputContains("src/Main.java"),
+ () ->
assertOutputContains("src/com.example.app/Application.java"),
+ () ->
assertOutputContains("resources-dir/i18n/messages_ru.properties"),
+ () ->
assertOutputContains("lib_folder/native-libs/lib_native.dll")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display structure with empty folders using recursive
deployment")
+ void structureWithEmptyFolders() {
+ // Given deployed unit with recursive option containing empty folders
+ String id = "test.empty.folder.unit.1";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", emptyFolderDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure
+ execute("node", "unit", "inspect", id, "--version", "1.0.0");
+
+ // Then verify structure includes empty folders
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ // Root level file
+ () -> assertOutputContains("existing-file.txt"),
+ // Empty folder at root level
+ () -> assertOutputContains("empty-folder"),
+ // Non-empty folder with contents
+ () -> assertOutputContains("non-empty-folder"),
+ () -> assertOutputContains("file-in-folder.txt"),
+ // Nested empty folder
+ () -> assertOutputContains("nested-empty-folder")
+ );
+ }
+
+ @Test
+ @DisplayName("Should display structure with empty folders in plain view")
+ void structureWithEmptyFoldersPlainView() {
+ // Given deployed unit with recursive option containing empty folders
+ String id = "test.empty.folder.unit.2";
+ execute("cluster", "unit", "deploy", id, "--version", "1.0.0",
"--path", emptyFolderDirectory.toString(), "--recursive");
+
+ await().untilAsserted(() -> {
+ execute("cluster", "unit", "list", "--plain", id);
+ assertExitCodeIsZero();
+ });
+
+ // When get structure with plain view
+ execute("node", "unit", "inspect", id, "--version", "1.0.0",
"--plain");
+
+ // Then verify files are shown with correct paths
+ assertAll(
+ this::assertExitCodeIsZero,
+ this::assertErrOutputIsEmpty,
+ () -> assertOutputContains("existing-file.txt"),
+ () ->
assertOutputContains("non-empty-folder/file-in-folder.txt")
+ );
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectReplCommandTest.java
similarity index 58%
copy from
modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
copy to
modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectReplCommandTest.java
index 00df44db495..404a6315861 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
+++
b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitInspectReplCommandTest.java
@@ -15,12 +15,25 @@
* limitations under the License.
*/
-package org.apache.ignite.internal.cli.commands.node.unit;
+package org.apache.ignite.internal.cli.commands.unit;
-import org.apache.ignite.internal.cli.commands.BaseCommand;
-import picocli.CommandLine.Command;
+import org.apache.ignite.internal.cli.commands.TopLevelCliReplCommand;
+import org.junit.jupiter.api.BeforeEach;
-/** Manages deployment units on node level. */
-@Command(name = "unit", subcommands = NodeUnitListCommand.class, description =
"Manages deployment units")
-public class NodeUnitCommand extends BaseCommand {
+/** Integration test for node unit inspect command in REPL mode. */
+class ItNodeUnitInspectReplCommandTest extends ItNodeUnitInspectCommandTest {
+ @BeforeEach
+ void connect() {
+ connect(NODE_URL);
+ }
+
+ @Override
+ protected Class<?> getCommandClass() {
+ return TopLevelCliReplCommand.class;
+ }
+
+ @Override
+ protected int errorExitCode() {
+ return 0;
+ }
}
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
index c1be70f23ab..763a5525699 100644
---
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
@@ -57,13 +57,21 @@ class ZipDeploymentContent implements DeploymentContent {
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);
+ stream.filter(path -> !path.equals(sourceDir)).forEach(path ->
{
+ Path relativePath = sourceDir.relativize(path);
try {
- ZipEntry zipEntry = new
ZipEntry(relativePath.toString().replace('\\', '/'));
- zos.putNextEntry(zipEntry);
- Files.copy(filePath, zos);
- zos.closeEntry();
+ if (Files.isDirectory(path)) {
+ // Add directory entry (with trailing slash)
+ ZipEntry zipEntry = new
ZipEntry(relativePath.toString().replace('\\', '/') + "/");
+ zos.putNextEntry(zipEntry);
+ zos.closeEntry();
+ } else {
+ // Add regular file entry
+ ZipEntry zipEntry = new
ZipEntry(relativePath.toString().replace('\\', '/'));
+ zos.putNextEntry(zipEntry);
+ Files.copy(path, zos);
+ zos.closeEntry();
+ }
} catch (IOException e) {
throw sneakyThrow(e);
}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.java
new file mode 100644
index 00000000000..726f8b8314a
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCall.java
@@ -0,0 +1,50 @@
+/*
+ * 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.node.unit;
+
+import jakarta.inject.Singleton;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+import org.apache.ignite.internal.cli.core.call.Call;
+import org.apache.ignite.internal.cli.core.call.CallOutput;
+import org.apache.ignite.internal.cli.core.call.DefaultCallOutput;
+import org.apache.ignite.internal.cli.core.exception.IgniteCliApiException;
+import org.apache.ignite.internal.cli.core.rest.ApiClientFactory;
+import org.apache.ignite.rest.client.api.DeploymentApi;
+import org.apache.ignite.rest.client.invoker.ApiException;
+import org.apache.ignite.rest.client.model.UnitFolder;
+
+/** Inspect unit on the node call. */
+@Singleton
+public class NodeUnitInspectCall implements Call<UnitInspectCallInput,
UnitFolder> {
+ private final ApiClientFactory clientFactory;
+
+ public NodeUnitInspectCall(ApiClientFactory clientFactory) {
+ this.clientFactory = clientFactory;
+ }
+
+ @Override
+ public CallOutput<UnitFolder> execute(UnitInspectCallInput input) {
+ try {
+ DeploymentApi api = new
DeploymentApi(clientFactory.getClient(input.url()));
+ UnitFolder structure = api.unitContent(input.unitId(),
input.version());
+ return DefaultCallOutput.success(structure);
+ } catch (ApiException e) {
+ return DefaultCallOutput.failure(new IgniteCliApiException(e,
input.url()));
+ }
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitInspectCallInput.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitInspectCallInput.java
new file mode 100644
index 00000000000..63abbb069f0
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitInspectCallInput.java
@@ -0,0 +1,80 @@
+/*
+ * 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.unit;
+
+import org.apache.ignite.internal.cli.core.call.CallInput;
+
+/** Input for unit inspect call. */
+public class UnitInspectCallInput implements CallInput {
+
+ private final String unitId;
+
+ private final String version;
+
+ private final String url;
+
+ private UnitInspectCallInput(String unitId, String version, String url) {
+ this.unitId = unitId;
+ this.version = version;
+ this.url = url;
+ }
+
+ public static UnitInspectCallInputBuilder builder() {
+ return new UnitInspectCallInputBuilder();
+ }
+
+ public String unitId() {
+ return unitId;
+ }
+
+ public String version() {
+ return version;
+ }
+
+ public String url() {
+ return url;
+ }
+
+ /** Builder for {@link UnitInspectCallInput}. */
+ public static class UnitInspectCallInputBuilder {
+ private String unitId;
+
+ private String version;
+
+ private String url;
+
+ public UnitInspectCallInputBuilder unitId(String unitId) {
+ this.unitId = unitId;
+ return this;
+ }
+
+ public UnitInspectCallInputBuilder version(String version) {
+ this.version = version;
+ return this;
+ }
+
+ public UnitInspectCallInputBuilder url(String url) {
+ this.url = url;
+ return this;
+ }
+
+ public UnitInspectCallInput build() {
+ return new UnitInspectCallInput(unitId, version, url);
+ }
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
index 00df44db495..f8a01f49d63 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitCommand.java
@@ -21,6 +21,6 @@ import org.apache.ignite.internal.cli.commands.BaseCommand;
import picocli.CommandLine.Command;
/** Manages deployment units on node level. */
-@Command(name = "unit", subcommands = NodeUnitListCommand.class, description =
"Manages deployment units")
+@Command(name = "unit", subcommands = {NodeUnitListCommand.class,
NodeUnitInspectCommand.class}, description = "Manages deployment units")
public class NodeUnitCommand extends BaseCommand {
}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectCommand.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectCommand.java
new file mode 100644
index 00000000000..8c474073b7d
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectCommand.java
@@ -0,0 +1,70 @@
+/*
+ * 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.commands.node.unit;
+
+import static
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION;
+import static
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_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;
+
+import jakarta.inject.Inject;
+import java.util.concurrent.Callable;
+import org.apache.ignite.internal.cli.call.node.unit.NodeUnitInspectCall;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+import org.apache.ignite.internal.cli.commands.BaseCommand;
+import org.apache.ignite.internal.cli.commands.node.NodeUrlProfileMixin;
+import org.apache.ignite.internal.cli.core.call.CallExecutionPipeline;
+import
org.apache.ignite.internal.cli.core.exception.handler.ClusterNotInitializedExceptionHandler;
+import org.apache.ignite.internal.cli.decorators.UnitInspectDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to inspect deployment unit. */
+@Command(name = "inspect", description = "Inspects the structure of a deployed
unit")
+public class NodeUnitInspectCommand extends BaseCommand implements
Callable<Integer> {
+
+ @Parameters(index = "0", description = "Deployment unit id")
+ private String unitId;
+
+ @Option(names = VERSION_OPTION, description = UNIT_VERSION_OPTION_DESC,
required = true)
+ private String version;
+
+ @Mixin
+ private NodeUrlProfileMixin nodeUrl;
+
+ @Option(names = PLAIN_OPTION, description = PLAIN_OPTION_DESC)
+ private boolean plain;
+
+ @Inject
+ private NodeUnitInspectCall call;
+
+ @Override
+ public Integer call() throws Exception {
+ return runPipeline(CallExecutionPipeline.builder(call)
+ .inputProvider(() -> UnitInspectCallInput.builder()
+ .unitId(unitId)
+ .version(version)
+ .url(nodeUrl.getNodeUrl())
+ .build())
+ .decorator(new UnitInspectDecorator(plain))
+
.exceptionHandler(ClusterNotInitializedExceptionHandler.createHandler("Cannot
inspect unit"))
+ );
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.java
new file mode 100644
index 00000000000..b0be2291d7e
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitInspectReplCommand.java
@@ -0,0 +1,74 @@
+/*
+ * 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.commands.node.unit;
+
+import static
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_OPTION;
+import static
org.apache.ignite.internal.cli.commands.Options.Constants.PLAIN_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;
+
+import jakarta.inject.Inject;
+import org.apache.ignite.internal.cli.call.node.unit.NodeUnitInspectCall;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+import org.apache.ignite.internal.cli.commands.BaseCommand;
+import org.apache.ignite.internal.cli.commands.node.NodeUrlMixin;
+import
org.apache.ignite.internal.cli.commands.questions.ConnectToClusterQuestion;
+import
org.apache.ignite.internal.cli.core.exception.handler.ClusterNotInitializedExceptionHandler;
+import org.apache.ignite.internal.cli.core.flow.builder.Flows;
+import org.apache.ignite.internal.cli.decorators.UnitInspectDecorator;
+import picocli.CommandLine.Command;
+import picocli.CommandLine.Mixin;
+import picocli.CommandLine.Option;
+import picocli.CommandLine.Parameters;
+
+/** Command to inspect deployment unit in REPL mode. */
+@Command(name = "inspect", description = "Inspects the structure of a deployed
unit")
+public class NodeUnitInspectReplCommand extends BaseCommand implements
Runnable {
+
+ @Parameters(index = "0", description = "Deployment unit id")
+ private String unitId;
+
+ @Option(names = VERSION_OPTION, description = UNIT_VERSION_OPTION_DESC,
required = true)
+ private String version;
+
+ @Mixin
+ private NodeUrlMixin nodeUrl;
+
+ @Option(names = PLAIN_OPTION, description = PLAIN_OPTION_DESC)
+ private boolean plain;
+
+ @Inject
+ private NodeUnitInspectCall call;
+
+ @Inject
+ private ConnectToClusterQuestion question;
+
+ @Override
+ public void run() {
+ runFlow(question.askQuestionIfNotConnected(nodeUrl.getNodeUrl())
+ .map(url -> UnitInspectCallInput.builder()
+ .unitId(unitId)
+ .version(version)
+ .url(url)
+ .build())
+ .then(Flows.fromCall(call))
+
.exceptionHandler(ClusterNotInitializedExceptionHandler.createReplHandler("Cannot
inspect unit"))
+ .print(new UnitInspectDecorator(plain))
+ );
+ }
+}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
index 013e9b3558d..016e44636ce 100644
---
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java
@@ -21,6 +21,8 @@ import org.apache.ignite.internal.cli.commands.BaseCommand;
import picocli.CommandLine.Command;
/** Manages deployment units on node level in REPL mode. */
-@Command(name = "unit", subcommands = NodeUnitListReplCommand.class,
description = "Manages deployment units")
+@Command(name = "unit",
+ subcommands = {NodeUnitListReplCommand.class,
NodeUnitInspectReplCommand.class},
+ description = "Manages deployment units")
public class NodeUnitReplCommand extends BaseCommand {
}
diff --git
a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.java
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.java
new file mode 100644
index 00000000000..6a5559b3d58
--- /dev/null
+++
b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecorator.java
@@ -0,0 +1,121 @@
+/*
+ * 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.decorators;
+
+import static org.apache.ignite.internal.util.IgniteUtils.readableSize;
+
+import java.util.List;
+import org.apache.ignite.internal.cli.core.decorator.Decorator;
+import org.apache.ignite.internal.cli.core.decorator.TerminalOutput;
+import org.apache.ignite.rest.client.model.UnitEntry;
+import org.apache.ignite.rest.client.model.UnitFile;
+import org.apache.ignite.rest.client.model.UnitFolder;
+
+/** Decorates unit inspect result as a tree. */
+public class UnitInspectDecorator implements Decorator<UnitFolder,
TerminalOutput> {
+
+ private static final String TREE_BRANCH = "+-- ";
+ private static final String TREE_LAST = "\\-- ";
+ private static final String TREE_VERTICAL = "| ";
+ private static final String TREE_SPACE = " ";
+
+ private final boolean plain;
+
+ public UnitInspectDecorator(boolean plain) {
+ this.plain = plain;
+ }
+
+ @Override
+ public TerminalOutput decorate(UnitFolder data) {
+ StringBuilder sb = new StringBuilder();
+
+ if (plain) {
+ renderPlain(data, sb, "");
+ } else {
+ sb.append(data.getName()).append('\n');
+ renderTree(data.getChildren(), sb, "");
+ }
+
+ String result = sb.toString();
+ return () -> result;
+ }
+
+ private static void renderTree(List<UnitEntry> children, StringBuilder sb,
String prefix) {
+ if (children == null || children.isEmpty()) {
+ return;
+ }
+
+ for (int i = 0; i < children.size(); i++) {
+ boolean isLast = i == children.size() - 1;
+ UnitEntry child = children.get(i);
+
+ Object actualInstance = child.getActualInstance();
+ if (actualInstance == null) {
+ continue;
+ }
+
+ String connector = isLast ? TREE_LAST : TREE_BRANCH;
+
+ if (actualInstance instanceof UnitFile) {
+ UnitFile file = (UnitFile) actualInstance;
+ sb.append(prefix).append(connector).append(file.getName())
+ .append(" (").append(readableSize(file.getSize(),
false)).append(')')
+ .append('\n');
+ } else if (actualInstance instanceof UnitFolder) {
+ UnitFolder folder = (UnitFolder) actualInstance;
+
sb.append(prefix).append(connector).append(folder.getName()).append('\n');
+ String newPrefix = prefix + (isLast ? TREE_SPACE :
TREE_VERTICAL);
+ renderTree(folder.getChildren(), sb, newPrefix);
+ }
+ }
+ }
+
+ private static void renderPlain(UnitFolder folder, StringBuilder sb,
String prefix) {
+ if (folder == null) {
+ return;
+ }
+
+ List<UnitEntry> children = folder.getChildren();
+ if (children == null || children.isEmpty()) {
+ return;
+ }
+
+ for (UnitEntry child : children) {
+ Object actualInstance = child.getActualInstance();
+ if (actualInstance == null) {
+ continue;
+ }
+
+ if (actualInstance instanceof UnitFile) {
+ UnitFile file = (UnitFile) actualInstance;
+ sb.append(prefix);
+ if (!prefix.isEmpty()) {
+ sb.append('/');
+ }
+ sb.append(file.getName())
+ .append(' ').append(file.getSize())
+ .append('\n');
+ } else if (actualInstance instanceof UnitFolder) {
+ UnitFolder subFolder = (UnitFolder) actualInstance;
+ String newPrefix = prefix.isEmpty() ? subFolder.getName() :
prefix + "/" + subFolder.getName();
+ renderPlain(subFolder, sb, newPrefix);
+ }
+ }
+ }
+
+}
diff --git
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCallTest.java
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCallTest.java
new file mode 100644
index 00000000000..5d6fab0a2f8
--- /dev/null
+++
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitInspectCallTest.java
@@ -0,0 +1,161 @@
+/*
+ * 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.node.unit;
+
+import static com.github.tomakehurst.wiremock.client.WireMock.get;
+import static com.github.tomakehurst.wiremock.client.WireMock.ok;
+import static com.github.tomakehurst.wiremock.client.WireMock.stubFor;
+import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.hasSize;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+
+import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
+import com.github.tomakehurst.wiremock.junit5.WireMockTest;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.apache.ignite.internal.cli.call.unit.UnitInspectCallInput;
+import org.apache.ignite.internal.cli.core.call.CallOutput;
+import org.apache.ignite.rest.client.model.UnitFolder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+@MicronautTest(rebuildContext = true)
+@WireMockTest
+class NodeUnitInspectCallTest {
+
+ private String url;
+
+ @Inject
+ private NodeUnitInspectCall call;
+
+ @BeforeEach
+ void setUp(WireMockRuntimeInfo wmRuntimeInfo) {
+ url = wmRuntimeInfo.getHttpBaseUrl();
+ }
+
+ @Test
+ @DisplayName("Should return unit structure with files")
+ void unitStructureWithFiles() {
+ // Given
+ String unitId = "test.unit";
+ String version = "1.0.0";
+
+ String json = "{"
+ + "\"type\":\"folder\","
+ + "\"name\":\"test.unit-1.0.0\","
+ + "\"size\":300,"
+ + "\"children\":["
+ + "{\"type\":\"file\",\"name\":\"file1.txt\",\"size\":100},"
+ + "{\"type\":\"file\",\"name\":\"file2.txt\",\"size\":200}"
+ + "]"
+ + "}";
+
+
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" +
unitId + "/" + version))
+ .willReturn(ok(json)));
+
+ // When
+ UnitInspectCallInput input = UnitInspectCallInput.builder()
+ .unitId(unitId)
+ .version(version)
+ .url(url)
+ .build();
+
+ CallOutput<UnitFolder> output = call.execute(input);
+
+ // Then
+ assertThat(output.hasError(), is(false));
+ assertThat(output.body(), is(notNullValue()));
+ assertThat(output.body().getName(), is("test.unit-1.0.0"));
+ assertThat(output.body().getChildren(), hasSize(2));
+ }
+
+ @Test
+ @DisplayName("Should return error when unit not found")
+ void unitNotFound() {
+ // Given
+ String unitId = "non.existing.unit";
+ String version = "1.0.0";
+
+
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" +
unitId + "/" + version))
+
.willReturn(com.github.tomakehurst.wiremock.client.WireMock.notFound()
+ .withBody("{\"title\":\"Not
Found\",\"status\":404,\"detail\":\"Unit not found\"}")));
+
+ // When
+ UnitInspectCallInput input = UnitInspectCallInput.builder()
+ .unitId(unitId)
+ .version(version)
+ .url(url)
+ .build();
+
+ CallOutput<UnitFolder> output = call.execute(input);
+
+ // Then
+ assertThat(output.hasError(), is(true));
+ assertThat(output.errorCause().getMessage(), containsString("404"));
+ }
+
+ @Test
+ @DisplayName("Should return unit structure with nested folders")
+ void unitStructureWithNestedFolders() {
+ // Given
+ String unitId = "test.unit";
+ String version = "1.0.0";
+
+ String json = "{"
+ + "\"type\":\"folder\","
+ + "\"name\":\"test.unit-1.0.0\","
+ + "\"size\":50,"
+ + "\"children\":["
+ + "{"
+ + "\"type\":\"folder\","
+ + "\"name\":\"subfolder\","
+ + "\"size\":50,"
+ + "\"children\":["
+ + "{\"type\":\"file\",\"name\":\"nested.txt\",\"size\":50}"
+ + "]"
+ + "}"
+ + "]"
+ + "}";
+
+
stubFor(get(urlEqualTo("/management/v1/deployment/node/units/structure/" +
unitId + "/" + version))
+ .willReturn(ok(json)));
+
+ // When
+ UnitInspectCallInput input = UnitInspectCallInput.builder()
+ .unitId(unitId)
+ .version(version)
+ .url(url)
+ .build();
+
+ CallOutput<UnitFolder> output = call.execute(input);
+
+ // Then
+ assertThat(output.hasError(), is(false));
+ assertThat(output.body(), is(notNullValue()));
+ assertThat(output.body().getName(), is("test.unit-1.0.0"));
+ assertThat(output.body().getChildren(), hasSize(1));
+
+ UnitFolder nested = (UnitFolder)
output.body().getChildren().get(0).getActualInstance();
+ assertThat(nested.getName(), is("subfolder"));
+ assertThat(nested.getChildren(), hasSize(1));
+ }
+}
diff --git
a/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecoratorTest.java
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecoratorTest.java
new file mode 100644
index 00000000000..f9c302abda8
--- /dev/null
+++
b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitInspectDecoratorTest.java
@@ -0,0 +1,179 @@
+/*
+ * 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.decorators;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsString;
+import static org.hamcrest.Matchers.is;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import org.apache.ignite.rest.client.model.UnitEntry;
+import org.apache.ignite.rest.client.model.UnitFile;
+import org.apache.ignite.rest.client.model.UnitFolder;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class UnitInspectDecoratorTest {
+
+ @Test
+ @DisplayName("Tree view should display folder with files")
+ void treeViewWithFiles() {
+ // Given
+ UnitFolder folder = createFolderWithFiles("test-unit",
+ createFile("file1.txt", 100),
+ createFile("file2.txt", 200));
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+ // When
+ String output = decorator.decorate(folder).toTerminalString();
+
+ // Then
+ String expected = "test-unit\n"
+ + "+-- file1.txt (100 B)\n"
+ + "\\-- file2.txt (200 B)\n";
+ assertThat(output, is(expected));
+ }
+
+ @Test
+ @DisplayName("Tree view should display nested folders")
+ void treeViewWithNestedFolders() {
+ // Given
+ UnitFolder subFolder = createFolderWithFiles("subfolder",
+ createFile("nested.txt", 50));
+
+ UnitEntry subFolderEntry = new UnitEntry();
+ subFolderEntry.setActualInstance(subFolder);
+
+ UnitFolder rootFolder = new UnitFolder()
+ .type(UnitFolder.TypeEnum.FOLDER)
+ .name("root")
+ .children(List.of(subFolderEntry));
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+ // When
+ String output = decorator.decorate(rootFolder).toTerminalString();
+
+ // Then
+ String expected = "root\n"
+ + "\\-- subfolder\n"
+ + " \\-- nested.txt (50 B)\n";
+ assertThat(output, is(expected));
+ }
+
+ @Test
+ @DisplayName("Plain view should display file paths")
+ void plainViewWithFiles() {
+ // Given
+ UnitFolder folder = createFolderWithFiles("test-unit",
+ createFile("file1.txt", 100),
+ createFile("file2.txt", 200));
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(true);
+
+ // When
+ String output = decorator.decorate(folder).toTerminalString();
+
+ // Then
+ String expected = "file1.txt 100\n"
+ + "file2.txt 200\n";
+ assertThat(output, is(expected));
+ }
+
+ @Test
+ @DisplayName("Plain view should display nested paths")
+ void plainViewWithNestedFolders() {
+ // Given
+ UnitFolder subFolder = createFolderWithFiles("subfolder",
+ createFile("nested.txt", 50));
+
+ UnitEntry subFolderEntry = new UnitEntry();
+ subFolderEntry.setActualInstance(subFolder);
+
+ UnitFolder rootFolder = new UnitFolder()
+ .type(UnitFolder.TypeEnum.FOLDER)
+ .name("root")
+ .children(List.of(subFolderEntry));
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(true);
+
+ // When
+ String output = decorator.decorate(rootFolder).toTerminalString();
+
+ // Then
+ String expected = "subfolder/nested.txt 50\n";
+ assertThat(output, is(expected));
+ }
+
+ @Test
+ @DisplayName("Should format file sizes correctly")
+ void fileSizeFormatting() {
+ // Given
+ UnitFolder folder = createFolderWithFiles("test-unit",
+ createFile("small.txt", 512),
+ createFile("medium.txt", 2048),
+ createFile("large.txt", 1024 * 1024 * 2));
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+ // When
+ String output = decorator.decorate(folder).toTerminalString();
+
+ // Then
+ assertThat(output, containsString("512 B"));
+ assertThat(output, containsString("KiB"));
+ assertThat(output, containsString("MiB"));
+ }
+
+ @Test
+ @DisplayName("Should handle empty folder")
+ void emptyFolder() {
+ // Given
+ UnitFolder folder = new UnitFolder()
+ .type(UnitFolder.TypeEnum.FOLDER)
+ .name("empty-folder")
+ .children(List.of());
+
+ UnitInspectDecorator decorator = new UnitInspectDecorator(false);
+
+ // When
+ String output = decorator.decorate(folder).toTerminalString();
+
+ // Then
+ assertThat(output, is("empty-folder\n"));
+ }
+
+ private static UnitFolder createFolderWithFiles(String name, UnitFile...
files) {
+ List<UnitEntry> children =
Arrays.stream(files).map(UnitEntry::new).collect(Collectors.toList());
+
+ return new UnitFolder()
+ .type(UnitFolder.TypeEnum.FOLDER)
+ .name(name)
+ .children(children);
+ }
+
+ private static UnitFile createFile(String name, long size) {
+ return new UnitFile()
+ .type(UnitFile.TypeEnum.FILE)
+ .name(name)
+ .size(size);
+ }
+}
diff --git a/modules/rest-api/build.gradle b/modules/rest-api/build.gradle
index e6264a7d023..abc7b98b935 100644
--- a/modules/rest-api/build.gradle
+++ b/modules/rest-api/build.gradle
@@ -26,6 +26,8 @@ dependencies {
annotationProcessor libs.micronaut.inject.annotation.processor
annotationProcessor libs.micronaut.openapi
+ compileOnly libs.spotbugs.annotations
+
implementation project(':ignite-api')
implementation project(':ignite-core')
implementation project(':ignite-configuration-api')
diff --git
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
index 0616ca2f810..932b14a7578 100644
---
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
+++
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
@@ -19,10 +19,12 @@ package org.apache.ignite.internal.rest.api.deployment;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonGetter;
+import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonSubTypes.Type;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
import java.util.List;
@@ -39,7 +41,11 @@ import
org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFolder;
* <p>The JSON representation includes a discriminator field "type" that can
be either
* "file" or "folder" to distinguish between the two implementations.
*/
-@Schema(description = "Unit content entry.")
+@Schema(
+ description = "Unit content entry.",
+ oneOf = {UnitFile.class, UnitFolder.class},
+ discriminatorProperty = "type"
+)
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
@@ -74,8 +80,12 @@ public interface UnitEntry {
* <p>This nested class implements {@link UnitEntry} and is serialized with
* a "type": "file" discriminator in JSON responses.
*/
- @Schema(description = "Unit content file.")
+ @Schema(name = "UnitFile", description = "Unit content file.")
public class UnitFile implements UnitEntry {
+ @SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification =
"Instance field required for JSON serialization")
+ @Schema(description = "Entry type discriminator.", requiredMode =
RequiredMode.REQUIRED, allowableValues = "file")
+ private final String type = "file";
+
@Schema(description = "Unit content file name.", requiredMode =
RequiredMode.REQUIRED)
private final String name;
@@ -97,6 +107,16 @@ public interface UnitEntry {
this.size = size;
}
+ /**
+ * Returns the type discriminator.
+ *
+ * @return the type string "file"
+ */
+ @JsonGetter("type")
+ public String type() {
+ return type;
+ }
+
/**
* Returns the name of this file.
*
@@ -130,14 +150,23 @@ public interface UnitEntry {
*
* <p>The size of a folder is calculated as the sum of all its children's
sizes.
*/
- @Schema(description = "Unit content folder.")
+ @Schema(name = "UnitFolder", description = "Unit content folder.")
public class UnitFolder implements UnitEntry {
+ @SuppressFBWarnings(value = "SS_SHOULD_BE_STATIC", justification =
"Instance field required for JSON serialization")
+ @Schema(description = "Entry type discriminator.", requiredMode =
RequiredMode.REQUIRED, allowableValues = "folder")
+ private final String type = "folder";
+
@Schema(description = "Unit content folder name.", requiredMode =
RequiredMode.REQUIRED)
private final String name;
@Schema(description = "Unit content folder elements.", requiredMode =
RequiredMode.REQUIRED)
+ @JsonInclude(JsonInclude.Include.ALWAYS)
private final List<UnitEntry> children;
+ @Schema(description = "Total size of folder contents in bytes
(computed as sum of all children).",
+ requiredMode = RequiredMode.REQUIRED, accessMode =
Schema.AccessMode.READ_ONLY)
+ private final long size;
+
/**
* Creates a new folder entry for JSON deserialization.
*
@@ -151,6 +180,17 @@ public interface UnitEntry {
) {
this.name = name;
this.children = children;
+ this.size = children == null ? 0 :
children.stream().mapToLong(UnitEntry::size).sum();
+ }
+
+ /**
+ * Returns the type discriminator.
+ *
+ * @return the type string "folder"
+ */
+ @JsonGetter("type")
+ public String type() {
+ return type;
}
/**
@@ -171,9 +211,10 @@ public interface UnitEntry {
*
* @return the total folder size in bytes
*/
+ @JsonGetter("size")
@Override
public long size() {
- return children.stream().mapToLong(UnitEntry::size).sum();
+ return size;
}
/**