This is an automated email from the ASF dual-hosted git repository. mpochatkin pushed a commit to branch IGNITE-27201 in repository https://gitbox.apache.org/repos/asf/ignite-3.git
commit 9647403cee955823e1806c8e991d196ee1bff571 Author: Pochatkin Mikhail <[email protected]> AuthorDate: Wed Jan 7 19:03:22 2026 +0300 IGNITE-27201 Add CLI command to inspect deployment unit file tree --- .../unit/ItNodeUnitStructureCommandTest.java | 170 ++++++++++++++++++ .../unit/ItNodeUnitStructureReplCommandTest.java} | 25 ++- .../cli/call/node/unit/NodeUnitStructureCall.java | 50 ++++++ .../cli/call/unit/UnitStructureCallInput.java | 80 +++++++++ .../cli/commands/node/unit/NodeUnitCommand.java | 2 +- .../commands/node/unit/NodeUnitReplCommand.java | 2 +- .../node/unit/NodeUnitStructureCommand.java | 70 ++++++++ .../node/unit/NodeUnitStructureReplCommand.java | 74 ++++++++ .../cli/decorators/UnitStructureDecorator.java | 121 +++++++++++++ .../call/node/unit/NodeUnitStructureCallTest.java | 161 +++++++++++++++++ .../cli/decorators/UnitStructureDecoratorTest.java | 193 +++++++++++++++++++++ .../internal/rest/api/deployment/UnitEntry.java | 44 ++++- 12 files changed, 980 insertions(+), 12 deletions(-) diff --git a/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java new file mode 100644 index 00000000000..7231daa375a --- /dev/null +++ b/modules/cli/src/integrationTest/java/org/apache/ignite/internal/cli/commands/unit/ItNodeUnitStructureCommandTest.java @@ -0,0 +1,170 @@ +/* + * 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 structure command. */ +public class ItNodeUnitStructureCommandTest extends CliIntegrationTest { + private static String testFile; + + private static Path testDirectory; + + @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")); + } + + @Test + @DisplayName("Should display unit structure with tree view") + void structureTreeView() { + // Given deployed unit + String id = "test.structure.unit.1"; + 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", "structure", id, "--version", "1.0.0"); + + // Then + assertAll( + this::assertExitCodeIsZero, + this::assertErrOutputIsEmpty, + () -> assertOutputContains(id), + () -> assertOutputContains("test.txt"), + () -> assertOutputContains("test2.txt"), + () -> assertOutputContains("test3.txt"), + () -> assertOutputContains(" B)") // File size display + ); + } + + @Test + @DisplayName("Should display unit structure with plain view") + void structurePlainView() { + // Given deployed unit + String id = "test.structure.unit.2"; + execute("cluster", "unit", "deploy", id, "--version", "1.0.0", "--path", testDirectory.toString()); + + await().untilAsserted(() -> { + execute("cluster", "unit", "list", "--plain", id); + assertExitCodeIsZero(); + }); + + // When get structure with plain option + execute("node", "unit", "structure", id, "--version", "1.0.0", "--plain"); + + // Then + assertAll( + this::assertExitCodeIsZero, + this::assertErrOutputIsEmpty, + () -> assertOutputContains("test.txt"), + () -> assertOutputContains("test2.txt"), + () -> assertOutputContains("test3.txt") + ); + } + + @Test + @DisplayName("Should display error when version is missing") + void structureVersionIsMandatory() { + // When get structure without version + execute("node", "unit", "structure", "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", "structure", "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", "structure", 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", "structure", id, "--version", "1.0.0"); + + // Then verify file sizes are displayed + assertAll( + this::assertExitCodeIsZero, + this::assertErrOutputIsEmpty, + () -> assertOutputContains(" B)") // Size in bytes + ); + } +} 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/ItNodeUnitStructureReplCommandTest.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/ItNodeUnitStructureReplCommandTest.java index 00df44db495..d74f6f87ed5 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/ItNodeUnitStructureReplCommandTest.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 structure command in REPL mode. */ +class ItNodeUnitStructureReplCommandTest extends ItNodeUnitStructureCommandTest { + @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/node/unit/NodeUnitStructureCall.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCall.java new file mode 100644 index 00000000000..392a7afc67c --- /dev/null +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCall.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.UnitStructureCallInput; +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; + +/** Get unit structure on the node call. */ +@Singleton +public class NodeUnitStructureCall implements Call<UnitStructureCallInput, UnitFolder> { + private final ApiClientFactory clientFactory; + + public NodeUnitStructureCall(ApiClientFactory clientFactory) { + this.clientFactory = clientFactory; + } + + @Override + public CallOutput<UnitFolder> execute(UnitStructureCallInput 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/UnitStructureCallInput.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitStructureCallInput.java new file mode 100644 index 00000000000..e5e4c7e0260 --- /dev/null +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/call/unit/UnitStructureCallInput.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 structure call. */ +public class UnitStructureCallInput implements CallInput { + + private final String unitId; + + private final String version; + + private final String url; + + private UnitStructureCallInput(String unitId, String version, String url) { + this.unitId = unitId; + this.version = version; + this.url = url; + } + + public static UnitStructureCallInputBuilder builder() { + return new UnitStructureCallInputBuilder(); + } + + public String unitId() { + return unitId; + } + + public String version() { + return version; + } + + public String url() { + return url; + } + + /** Builder for {@link UnitStructureCallInput}. */ + public static class UnitStructureCallInputBuilder { + private String unitId; + + private String version; + + private String url; + + public UnitStructureCallInputBuilder unitId(String unitId) { + this.unitId = unitId; + return this; + } + + public UnitStructureCallInputBuilder version(String version) { + this.version = version; + return this; + } + + public UnitStructureCallInputBuilder url(String url) { + this.url = url; + return this; + } + + public UnitStructureCallInput build() { + return new UnitStructureCallInput(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..96d1076ed8d 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, NodeUnitStructureCommand.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/NodeUnitReplCommand.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitReplCommand.java index 013e9b3558d..9f364215e92 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,6 @@ 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, NodeUnitStructureReplCommand.class}, description = "Manages deployment units") public class NodeUnitReplCommand extends BaseCommand { } diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.java new file mode 100644 index 00000000000..91491bc9c3f --- /dev/null +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureCommand.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.NodeUnitStructureCall; +import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput; +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.UnitStructureDecorator; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** Command to show deployment unit structure. */ +@Command(name = "structure", description = "Shows the structure of a deployed unit") +public class NodeUnitStructureCommand 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 NodeUnitStructureCall call; + + @Override + public Integer call() throws Exception { + return runPipeline(CallExecutionPipeline.builder(call) + .inputProvider(() -> UnitStructureCallInput.builder() + .unitId(unitId) + .version(version) + .url(nodeUrl.getNodeUrl()) + .build()) + .decorator(new UnitStructureDecorator(plain)) + .exceptionHandler(ClusterNotInitializedExceptionHandler.createHandler("Cannot get unit structure")) + ); + } +} diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.java new file mode 100644 index 00000000000..c5c46ca724c --- /dev/null +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/commands/node/unit/NodeUnitStructureReplCommand.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.NodeUnitStructureCall; +import org.apache.ignite.internal.cli.call.unit.UnitStructureCallInput; +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.UnitStructureDecorator; +import picocli.CommandLine.Command; +import picocli.CommandLine.Mixin; +import picocli.CommandLine.Option; +import picocli.CommandLine.Parameters; + +/** Command to show deployment unit structure in REPL mode. */ +@Command(name = "structure", description = "Shows the structure of a deployed unit") +public class NodeUnitStructureReplCommand 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 NodeUnitStructureCall call; + + @Inject + private ConnectToClusterQuestion question; + + @Override + public void run() { + runFlow(question.askQuestionIfNotConnected(nodeUrl.getNodeUrl()) + .map(url -> UnitStructureCallInput.builder() + .unitId(unitId) + .version(version) + .url(url) + .build()) + .then(Flows.fromCall(call)) + .exceptionHandler(ClusterNotInitializedExceptionHandler.createReplHandler("Cannot get unit structure")) + .print(new UnitStructureDecorator(plain)) + ); + } +} diff --git a/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.java b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.java new file mode 100644 index 00000000000..f2e5c9fae90 --- /dev/null +++ b/modules/cli/src/main/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecorator.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 structure as a tree. */ +public class UnitStructureDecorator 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 UnitStructureDecorator(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()); + sb.append(" (").append(readableSize(file.getSize(), false)).append(")"); + sb.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()); + sb.append(" ").append(file.getSize()); + sb.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/NodeUnitStructureCallTest.java b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCallTest.java new file mode 100644 index 00000000000..d974d012be9 --- /dev/null +++ b/modules/cli/src/test/java/org/apache/ignite/internal/cli/call/node/unit/NodeUnitStructureCallTest.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.UnitStructureCallInput; +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 NodeUnitStructureCallTest { + + private String url; + + @Inject + private NodeUnitStructureCall 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 + UnitStructureCallInput input = UnitStructureCallInput.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 + UnitStructureCallInput input = UnitStructureCallInput.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 + UnitStructureCallInput input = UnitStructureCallInput.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/UnitStructureDecoratorTest.java b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecoratorTest.java new file mode 100644 index 00000000000..b5e7001a6fc --- /dev/null +++ b/modules/cli/src/test/java/org/apache/ignite/internal/cli/decorators/UnitStructureDecoratorTest.java @@ -0,0 +1,193 @@ +/* + * 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.not; + +import java.util.List; +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 UnitStructureDecoratorTest { + + @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)); + + UnitStructureDecorator decorator = new UnitStructureDecorator(false); + + // When + String output = decorator.decorate(folder).toTerminalString(); + + // Then + assertThat(output, containsString("test-unit")); + assertThat(output, containsString("file1.txt")); + assertThat(output, containsString("file2.txt")); + assertThat(output, containsString("100 B")); + assertThat(output, containsString("200 B")); + assertThat(output, containsString("+--")); + assertThat(output, containsString("\\--")); + } + + @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)); + + UnitStructureDecorator decorator = new UnitStructureDecorator(false); + + // When + String output = decorator.decorate(rootFolder).toTerminalString(); + + // Then + assertThat(output, containsString("root")); + assertThat(output, containsString("subfolder")); + assertThat(output, containsString("nested.txt")); + assertThat(output, containsString("\\--")); + assertThat(output, containsString(" \\--")); // Nested indent + } + + @Test + @DisplayName("Plain view should display file paths") + void plainViewWithFiles() { + // Given + UnitFolder folder = createFolderWithFiles("test-unit", + createFile("file1.txt", 100), + createFile("file2.txt", 200)); + + UnitStructureDecorator decorator = new UnitStructureDecorator(true); + + // When + String output = decorator.decorate(folder).toTerminalString(); + + // Then + assertThat(output, containsString("file1.txt")); + assertThat(output, containsString("file2.txt")); + assertThat(output, containsString("100")); + assertThat(output, containsString("200")); + // Should not contain tree characters in plain mode + assertThat(output, not(containsString("├──"))); + assertThat(output, not(containsString("└──"))); + } + + @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)); + + UnitStructureDecorator decorator = new UnitStructureDecorator(true); + + // When + String output = decorator.decorate(rootFolder).toTerminalString(); + + // Then + assertThat(output, containsString("subfolder/nested.txt")); + assertThat(output, containsString("50")); + } + + @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)); + + UnitStructureDecorator decorator = new UnitStructureDecorator(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()); + + UnitStructureDecorator decorator = new UnitStructureDecorator(false); + + // When + String output = decorator.decorate(folder).toTerminalString(); + + // Then + assertThat(output, containsString("empty-folder")); + } + + private UnitFolder createFolderWithFiles(String name, UnitFile... files) { + List<UnitEntry> children = new java.util.ArrayList<>(); + long totalSize = 0; + + for (UnitFile file : files) { + UnitEntry entry = new UnitEntry(); + entry.setActualInstance(file); + children.add(entry); + totalSize += file.getSize(); + } + + return new UnitFolder() + .type(UnitFolder.TypeEnum.FOLDER) + .name(name) + .children(children); + } + + private UnitFile createFile(String name, long size) { + return new UnitFile() + .type(UnitFile.TypeEnum.FILE) + .name(name) + .size(size); + } +} 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..e56fdc9a722 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 @@ -39,7 +39,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 +78,11 @@ 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 { + @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 +104,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 +147,21 @@ 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 { + @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) 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 +175,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 +206,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; } /**
