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

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

commit 1b89cf845d9899ad42b9dcb7942230ba31e1bbcf
Author: Pochatkin Mikhail <[email protected]>
AuthorDate: Wed Dec 17 16:53:30 2025 +0300

    IGNITE-27200 Add REST for access deployment structure
---
 .../ignite/internal/deployment/DeployFiles.java    |   3 +-
 .../apache/ignite/internal/deployment/Unit.java    |  43 ++++-
 .../internal/deployunit/DeploymentManagerImpl.java |  15 ++
 .../internal/deployunit/FileDeployerService.java   |  47 +++++
 .../internal/deployunit/IgniteDeployment.java      |   9 +
 .../internal/deployunit/UnitStructureBuilder.java  |  96 +++++++++++
 .../internal/deployunit/structure/UnitEntry.java   |  48 ++++++
 .../internal/deployunit/structure/UnitFile.java    |  69 ++++++++
 .../internal/deployunit/structure/UnitFolder.java  |  92 ++++++++++
 .../ignite/deployment/FileDeployerServiceTest.java |  34 ++++
 .../compute/util/DummyIgniteDeployment.java        |   6 +
 .../internal/testframework/IgniteTestUtils.java    |   2 +-
 .../rest/api/deployment/DeploymentCodeApi.java     |  25 +++
 .../internal/rest/api/deployment/UnitEntry.java    | 189 +++++++++++++++++++++
 .../DeploymentManagementControllerTest.java        |  48 ++++++
 .../deployment/DeploymentManagementController.java |  25 +++
 16 files changed, 743 insertions(+), 8 deletions(-)

diff --git 
a/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/DeployFiles.java
 
b/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/DeployFiles.java
index bf0424489b4..0dc7f914667 100644
--- 
a/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/DeployFiles.java
+++ 
b/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/DeployFiles.java
@@ -171,10 +171,11 @@ class DeployFiles {
 
         assertThat(deploy, willBe(true));
 
-        Unit unit = new Unit(entryNode, workDir, id, version, files);
+        Unit unit = new Unit(entryNode, id, version, files);
 
         for (DeployFile file : files) {
             unit.verify(file, entryNode);
+            unit.verifyByRest(entryNode);
         }
 
         return unit;
diff --git 
a/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/Unit.java
 
b/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/Unit.java
index c2ae55763cf..2aa0acd74e4 100644
--- 
a/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/Unit.java
+++ 
b/modules/code-deployment/src/integrationTest/java/org/apache/ignite/internal/deployment/Unit.java
@@ -18,8 +18,10 @@
 package org.apache.ignite.internal.deployment;
 
 import static java.util.concurrent.TimeUnit.SECONDS;
+import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
 import static org.awaitility.Awaitility.await;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
@@ -33,7 +35,9 @@ import java.util.zip.ZipInputStream;
 import org.apache.ignite.deployment.version.Version;
 import org.apache.ignite.internal.app.IgniteImpl;
 import 
org.apache.ignite.internal.deployunit.configuration.DeploymentExtensionConfiguration;
-import org.hamcrest.Matchers;
+import org.apache.ignite.internal.deployunit.structure.UnitEntry;
+import org.apache.ignite.internal.deployunit.structure.UnitFile;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 
 class Unit {
     private final IgniteImpl deployedNode;
@@ -44,11 +48,8 @@ class Unit {
 
     private final List<DeployFile> files;
 
-    private final Path workDir;
-
-    Unit(IgniteImpl deployedNode, Path workDir, String id, Version version, 
List<DeployFile> files) {
+    Unit(IgniteImpl deployedNode, String id, Version version, List<DeployFile> 
files) {
         this.deployedNode = deployedNode;
-        this.workDir = workDir;
         this.id = id;
         this.version = version;
         this.files = files;
@@ -143,10 +144,40 @@ class Unit {
             try {
                 Path filePath = 
nodeUnitDirectory.resolve(file.file().getFileName());
                 assertTrue(Files.exists(filePath));
-                assertThat(Files.size(filePath), 
Matchers.is(file.expectedSize()));
+                assertThat(Files.size(filePath), is(file.expectedSize()));
+            } catch (IOException e) {
+                fail(e);
+            }
+        }
+    }
+
+    void verifyByRest(IgniteImpl entryNode) {
+        Path currentDir = getNodeUnitDirectory(entryNode);
+
+        CompletableFuture<Void> result = 
entryNode.deployment().nodeUnitFileStructure(id, version).thenAccept(folder -> {
+            try {
+                processEntry(folder, currentDir);
             } catch (IOException e) {
                 fail(e);
             }
+        });
+
+        assertThat(result, willCompleteSuccessfully());
+    }
+
+    private static void processEntry(UnitEntry entry, Path currentDir) throws 
IOException {
+        if (entry instanceof UnitFile) {
+            UnitFile file = (UnitFile) entry;
+            Path filePath = currentDir.resolve(file.name());
+            assertTrue(Files.exists(filePath));
+            assertThat(Files.size(filePath), is(file.size()));
+        } else if (entry instanceof UnitFolder) {
+            Path dir = currentDir.resolve(entry.name());
+            for (UnitEntry child : ((UnitFolder) entry).children()) {
+                processEntry(child, dir);
+            }
+        } else {
+            fail(new IllegalStateException("Unit entry type not supported."));
         }
     }
 }
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/DeploymentManagerImpl.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/DeploymentManagerImpl.java
index bd7ac3c8620..02260ee87ec 100644
--- 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/DeploymentManagerImpl.java
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/DeploymentManagerImpl.java
@@ -56,6 +56,7 @@ import 
org.apache.ignite.internal.deployunit.metastore.NodeEventCallback;
 import org.apache.ignite.internal.deployunit.metastore.NodeStatusWatchListener;
 import 
org.apache.ignite.internal.deployunit.metastore.status.UnitClusterStatus;
 import org.apache.ignite.internal.deployunit.metastore.status.UnitNodeStatus;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.deployunit.tempstorage.TempStorageProvider;
 import org.apache.ignite.internal.logger.IgniteLogger;
 import org.apache.ignite.internal.logger.Loggers;
@@ -354,6 +355,20 @@ public class DeploymentManagerImpl implements 
IgniteDeployment {
                 .thenApply(DeploymentManagerImpl::extractDeploymentStatus);
     }
 
+    @Override
+    public CompletableFuture<UnitFolder> nodeUnitFileStructure(String id, 
Version version) {
+        checkId(id);
+        Objects.requireNonNull(version);
+
+        return deploymentUnitStore.getNodeStatus(nodeName, id, 
version).thenCompose(nodeStatus -> {
+            if (nodeStatus == null) {
+                return failedFuture(new DeploymentUnitNotFoundException(id, 
version));
+            }
+
+            return deployer.getUnitStructure(id, version);
+        });
+    }
+
     @Override
     public CompletableFuture<Boolean> onDemandDeploy(String id, Version 
version) {
         return deploymentUnitStore.getAllNodeStatuses(id, version)
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/FileDeployerService.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/FileDeployerService.java
index 63aa198369f..7db02b6acdb 100644
--- 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/FileDeployerService.java
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/FileDeployerService.java
@@ -33,6 +33,7 @@ import java.util.concurrent.Executors;
 import org.apache.ignite.deployment.version.Version;
 import org.apache.ignite.internal.deployunit.DeployerProcessor.DeployArg;
 import 
org.apache.ignite.internal.deployunit.exception.DeploymentUnitNotFoundException;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.deployunit.tempstorage.TempStorage;
 import org.apache.ignite.internal.deployunit.tempstorage.TempStorageProvider;
 import 
org.apache.ignite.internal.deployunit.tempstorage.TempStorageProviderImpl;
@@ -40,6 +41,7 @@ import org.apache.ignite.internal.logger.IgniteLogger;
 import org.apache.ignite.internal.logger.Loggers;
 import org.apache.ignite.internal.thread.IgniteThreadFactory;
 import org.apache.ignite.internal.util.IgniteUtils;
+import org.jetbrains.annotations.Nullable;
 
 /**
  * Service for file deploying on local File System.
@@ -144,6 +146,51 @@ public class FileDeployerService {
         }, executor);
     }
 
+    /**
+     * Returns unit content representation with tree structure.
+     *
+     * @param id Unit identifier.
+     * @param version Unit version.
+     */
+    public CompletableFuture<UnitFolder> getUnitStructure(String id, Version 
version) {
+        return CompletableFuture.supplyAsync(() -> {
+            try {
+                UnitStructureBuilder builder = new UnitStructureBuilder();
+                Path unitFolder = unitPath(id, version);
+                builder.pushFolder(id + "-" + version);
+
+                Files.walkFileTree(unitFolder, new SimpleFileVisitor<>() {
+
+                    @Override
+                    public FileVisitResult preVisitDirectory(Path dir, 
BasicFileAttributes attrs) throws IOException {
+                        if (!Files.isSameFile(dir, unitFolder)) {
+                            builder.pushFolder(dir.getFileName().toString());
+                        }
+                        return FileVisitResult.CONTINUE;
+                    }
+
+                    @Override
+                    public FileVisitResult visitFile(Path file, 
BasicFileAttributes attrs) {
+                        builder.addFile(file.getFileName().toString(), 
attrs.size());
+                        return FileVisitResult.CONTINUE;
+                    }
+
+                    @Override
+                    public FileVisitResult postVisitDirectory(Path dir, 
@Nullable IOException exc) throws IOException {
+                        if (!Files.isSameFile(dir, unitFolder)) {
+                            builder.popFolder(dir.getFileName().toString());
+                        }
+                        return FileVisitResult.CONTINUE;
+                    }
+                });
+                return builder.build();
+            } catch (IOException e) {
+                LOG.error("Failed to get content for unit " + id + ":" + 
version, e);
+                return null;
+            }
+        }, executor);
+    }
+
     /**
      * Returns path to unit folder.
      *
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/IgniteDeployment.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/IgniteDeployment.java
index 3e96298bad2..dfdff2d0624 100644
--- 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/IgniteDeployment.java
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/IgniteDeployment.java
@@ -20,6 +20,7 @@ package org.apache.ignite.internal.deployunit;
 import java.util.List;
 import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.deployment.version.Version;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.manager.IgniteComponent;
 
 /**
@@ -149,4 +150,12 @@ public interface IgniteDeployment extends IgniteComponent {
      * @return Future with the latest version of the deployment unit.
      */
     CompletableFuture<Version> detectLatestDeployedVersion(String id);
+
+    /**
+     * Returns unit content tree structure.
+     *
+     * @param unitId Deployment unit identifier.
+     * @param version Deployment unit version.
+     */
+    CompletableFuture<UnitFolder> nodeUnitFileStructure(String unitId, Version 
version);
 }
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/UnitStructureBuilder.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/UnitStructureBuilder.java
new file mode 100644
index 00000000000..0fcd5c5b2c0
--- /dev/null
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/UnitStructureBuilder.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.deployunit;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+import org.apache.ignite.internal.deployunit.structure.UnitFile;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
+
+/**
+ * Builder for constructing hierarchical deployment unit structures.
+ *
+ * <p>This class uses a stack-based approach to build a tree structure of 
folders and files
+ * representing the content of a deployment unit. Folders can be nested by 
pushing and popping
+ * them on the internal stack, while files are added to the current (top) 
folder.
+ *
+ * @see UnitFolder
+ * @see UnitFile
+ */
+public class UnitStructureBuilder {
+    /** Stack of folders used to build the hierarchical structure. */
+    private final Deque<UnitFolder> folderStack = new ArrayDeque<>();
+
+    /**
+     * Pushes a new folder onto the stack and adds it as a child to the 
current folder.
+     *
+     * <p>If there is already a folder on the stack, the new folder is added 
as a child
+     * to it. The new folder becomes the current folder (top of the stack) to 
which
+     * subsequent files and folders will be added.
+     *
+     * @param folderName the name of the folder to push
+     */
+    public void pushFolder(String folderName) {
+        UnitFolder folder = folderStack.peek();
+        UnitFolder newFolder = new UnitFolder(folderName);
+        if (folder != null) {
+            folder.addChild(newFolder);
+        }
+        folderStack.push(newFolder);
+    }
+
+    /**
+     * Pops the current folder from the stack.
+     *
+     * <p>The provided folder name is verified against the name of the folder 
being popped
+     * to ensure correct nesting structure.
+     *
+     * @param folderName the expected name of the folder being popped (for 
verification)
+     */
+    public void popFolder(String folderName) {
+        UnitFolder lastFolder = folderStack.pop();
+        assert lastFolder.name().equals(folderName);
+    }
+
+    /**
+     * Adds a file to the current folder.
+     *
+     * <p>The file is added as a child to the folder at the top of the stack.
+     *
+     * @param fileName the name of the file
+     * @param size the size of the file in bytes
+     */
+    public void addFile(String fileName, long size) {
+        UnitFolder folder = folderStack.peek();
+        folder.addChild(new UnitFile(fileName, size));
+    }
+
+    /**
+     * Builds and returns the root folder structure.
+     *
+     * <p>This method pops and returns the last remaining folder from the 
stack,
+     * which should be the root of the built structure. The stack should 
contain
+     * exactly one folder when this method is called.
+     *
+     * @return the root folder of the built structure
+     * @throws java.util.NoSuchElementException if the stack is empty
+     */
+    public UnitFolder build() {
+        return folderStack.pop();
+    }
+}
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitEntry.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitEntry.java
new file mode 100644
index 00000000000..134f6044d4c
--- /dev/null
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitEntry.java
@@ -0,0 +1,48 @@
+/*
+ * 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.deployunit.structure;
+
+/**
+ * Represents an entry in a deployment unit's content structure.
+ *
+ * <p>A unit entry can be either a file or a folder within a deployment unit.
+ * This interface provides basic operations to access the entry's metadata 
such as name and size.
+ *
+ * @see UnitFile
+ * @see UnitFolder
+ */
+public interface UnitEntry {
+    /**
+     * Returns the name of this entry.
+     *
+     * <p>For files, this is the file name. For folders, this is the folder 
name.
+     *
+     * @return the entry name, never {@code null}
+     */
+    String name();
+
+    /**
+     * Returns the size of this entry in bytes.
+     *
+     * <p>For files, this is the actual file size. For folders, this is the sum
+     * of all children sizes (recursive).
+     *
+     * @return the entry size in bytes, always non-negative
+     */
+    long size();
+}
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFile.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFile.java
new file mode 100644
index 00000000000..5db505599c7
--- /dev/null
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFile.java
@@ -0,0 +1,69 @@
+/*
+ * 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.deployunit.structure;
+
+import java.util.Objects;
+
+/**
+ * Represents a file entry in a deployment unit.
+ *
+ * <p>This implementation of {@link UnitEntry} holds metadata about a single 
file,
+ * including its name and size in bytes.
+ */
+public class UnitFile implements UnitEntry {
+    /** The name of the file. */
+    private final String name;
+
+    /** The size of the file in bytes. */
+    private final long size;
+
+    /**
+     * Creates a new file entry.
+     *
+     * @param name the file name
+     * @param size the file size in bytes
+     */
+    public UnitFile(String name, long size) {
+        this.name = name;
+        this.size = size;
+    }
+
+    @Override
+    public String name() {
+        return name;
+    }
+
+    @Override
+    public long size() {
+        return size;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        UnitFile unitFile = (UnitFile) o;
+        return size == unitFile.size && Objects.equals(name, unitFile.name);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, size);
+    }
+}
diff --git 
a/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFolder.java
 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFolder.java
new file mode 100644
index 00000000000..5b81e08f9cd
--- /dev/null
+++ 
b/modules/code-deployment/src/main/java/org/apache/ignite/internal/deployunit/structure/UnitFolder.java
@@ -0,0 +1,92 @@
+/*
+ * 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.deployunit.structure;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Represents a folder (directory) entry in a deployment unit.
+ *
+ * <p>This implementation of {@link UnitEntry} holds metadata about a folder,
+ * including its name and children entries. The folder can contain both files
+ * ({@link UnitFile}) and other folders ({@link UnitFolder}), creating a 
hierarchical structure.
+ *
+ * <p>The size of a folder is calculated as the sum of all its children's 
sizes.
+ */
+public class UnitFolder implements UnitEntry {
+    /** The name of the folder. */
+    private final String name;
+
+    /** The list of children entries in this folder. */
+    private final List<UnitEntry> children = new ArrayList<>();
+
+    /**
+     * Creates a new folder entry with the specified name.
+     *
+     * @param name the folder name
+     */
+    public UnitFolder(String name) {
+        this.name = name;
+    }
+
+    @Override
+    public String name() {
+        return name;
+    }
+
+    @Override
+    public long size() {
+        return children.stream().mapToLong(UnitEntry::size).sum();
+    }
+
+    /**
+     * Adds a child entry to this folder.
+     *
+     * @param entry the child entry to add (file or folder)
+     */
+    public void addChild(UnitEntry entry) {
+        children.add(entry);
+    }
+
+    /**
+     * Returns an unmodifiable collection of children entries.
+     *
+     * @return the children entries, never {@code null}
+     */
+    public Collection<UnitEntry> children() {
+        return Collections.unmodifiableCollection(children);
+    }
+
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        UnitFolder folder = (UnitFolder) o;
+        return Objects.equals(name, folder.name) && Objects.equals(children, 
folder.children);
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, children);
+    }
+}
diff --git 
a/modules/code-deployment/src/test/java/org/apache/ignite/deployment/FileDeployerServiceTest.java
 
b/modules/code-deployment/src/test/java/org/apache/ignite/deployment/FileDeployerServiceTest.java
index d21ea58cca6..ce778b2346b 100644
--- 
a/modules/code-deployment/src/test/java/org/apache/ignite/deployment/FileDeployerServiceTest.java
+++ 
b/modules/code-deployment/src/test/java/org/apache/ignite/deployment/FileDeployerServiceTest.java
@@ -19,8 +19,11 @@ package org.apache.ignite.deployment;
 
 import static org.apache.ignite.deployment.version.Version.parseVersion;
 import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willBe;
+import static 
org.apache.ignite.internal.testframework.matchers.CompletableFutureMatcher.willCompleteSuccessfully;
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
 import static org.hamcrest.Matchers.equalTo;
+import static org.junit.jupiter.api.Assertions.fail;
 
 import java.io.ByteArrayInputStream;
 import java.io.FileNotFoundException;
@@ -38,6 +41,8 @@ import 
org.apache.ignite.internal.deployunit.FileDeployerService;
 import org.apache.ignite.internal.deployunit.StreamDeploymentUnit;
 import org.apache.ignite.internal.deployunit.UnitContent;
 import 
org.apache.ignite.internal.deployunit.exception.DeploymentUnitReadException;
+import org.apache.ignite.internal.deployunit.structure.UnitFile;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.testframework.IgniteTestUtils;
 import org.apache.ignite.internal.testframework.WorkDirectory;
 import org.apache.ignite.internal.testframework.WorkDirectoryExtension;
@@ -86,6 +91,35 @@ public class FileDeployerServiceTest {
         }
     }
 
+    @Test
+    public void testUnitStructure() throws Exception {
+        try (StreamDeploymentUnit unit = content()) {
+            CompletableFuture<Boolean> deployed = service.deploy("id", 
parseVersion("1.0.0"), unit);
+            assertThat(deployed, willBe(true));
+        }
+
+        CompletableFuture<UnitFolder> folderFuture = 
service.getUnitStructure("id", parseVersion("1.0.0"));
+        assertThat(folderFuture, willCompleteSuccessfully());
+
+        UnitFolder folder = folderFuture.get();
+
+        assertThat(folder.children(),
+                containsInAnyOrder(
+                        Stream.of(file1, file2, file3)
+                                
.map(FileDeployerServiceTest::toUnitFile).toArray()
+                )
+        );
+    }
+
+    private static UnitFile toUnitFile(Path file) {
+        try {
+            return new UnitFile(file.getFileName().toString(), 
Files.size(file));
+        } catch (IOException e) {
+            fail(e);
+            return null;
+        }
+    }
+
     private StreamDeploymentUnit content() {
         Map<String, InputStream> map = Stream.of(file1, file2, file3)
                 .collect(Collectors.toMap(it -> it.getFileName().toString(), 
it -> {
diff --git 
a/modules/compute/src/test/java/org/apache/ignite/internal/compute/util/DummyIgniteDeployment.java
 
b/modules/compute/src/test/java/org/apache/ignite/internal/compute/util/DummyIgniteDeployment.java
index be75511e21a..eb43ae31bc9 100644
--- 
a/modules/compute/src/test/java/org/apache/ignite/internal/compute/util/DummyIgniteDeployment.java
+++ 
b/modules/compute/src/test/java/org/apache/ignite/internal/compute/util/DummyIgniteDeployment.java
@@ -35,6 +35,7 @@ import org.apache.ignite.internal.deployunit.IgniteDeployment;
 import org.apache.ignite.internal.deployunit.NodesToDeploy;
 import org.apache.ignite.internal.deployunit.UnitStatuses;
 import 
org.apache.ignite.internal.deployunit.exception.DeploymentUnitNotFoundException;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.manager.ComponentContext;
 
 /**
@@ -118,6 +119,11 @@ public class DummyIgniteDeployment implements 
IgniteDeployment {
                 .orElseThrow());
     }
 
+    @Override
+    public CompletableFuture<UnitFolder> nodeUnitFileStructure(String unitId, 
Version version) {
+        throw new UnsupportedOperationException("Not implemented");
+    }
+
     @Override
     public CompletableFuture<Void> startAsync(ComponentContext 
componentContext) {
         return nullCompletedFuture();
diff --git 
a/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/IgniteTestUtils.java
 
b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/IgniteTestUtils.java
index 08bcbf50428..02157f553c0 100644
--- 
a/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/IgniteTestUtils.java
+++ 
b/modules/core/src/testFixtures/java/org/apache/ignite/internal/testframework/IgniteTestUtils.java
@@ -428,7 +428,7 @@ public final class IgniteTestUtils {
      * @param t   Throwable to check.
      * @param cls Cause classes to check.
      * @param msg Message text that should be in cause (if {@code null}, 
message won't be checked).
-     * @return reference to the cause error if found, otherwise returns {@code 
null}.
+     * @return reference to the cause error if found, otherwise returns {@code 
null}.27
      */
     public static <T extends Throwable> @Nullable T cause(
             Throwable t,
diff --git 
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/DeploymentCodeApi.java
 
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/DeploymentCodeApi.java
index 5a1d13cc293..fbfeeb511cc 100644
--- 
a/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/DeploymentCodeApi.java
+++ 
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/DeploymentCodeApi.java
@@ -40,6 +40,7 @@ import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.CompletableFuture;
 import org.apache.ignite.internal.rest.api.Problem;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFolder;
 import org.reactivestreams.Publisher;
 
 /**
@@ -234,4 +235,28 @@ public interface DeploymentCodeApi {
             @Schema(name = "statuses", description = "Deployment status 
filter.")
             Optional<List<DeploymentStatus>> statuses
     );
+
+    /**
+     * Unit content REST method.
+     */
+    @Operation(
+            operationId = "unitContent",
+            summary = "Get unit contents.",
+            description = "Returns a folder representation with unit content."
+    )
+    @ApiResponse(responseCode = "200",
+            description = "Unit content returned successfully.",
+            content = @Content(mediaType = APPLICATION_JSON, schema = 
@Schema(implementation = UnitFolder.class))
+    )
+    @ApiResponse(responseCode = "500",
+            description = "Internal error.",
+            content = @Content(mediaType = PROBLEM_JSON, schema = 
@Schema(implementation = Problem.class))
+    )
+    @Get("node/units/structure/{unitId}/{unitVersion}")
+    CompletableFuture<UnitFolder> unitStructure(
+            @Schema(name = "unitId", description = "The ID of the deployment 
unit.")
+            String unitId,
+            @Schema(name = "unitVersion", description = "The version of the 
deployment unit.")
+            String unitVersion
+    );
 }
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
new file mode 100644
index 00000000000..0616ca2f810
--- /dev/null
+++ 
b/modules/rest-api/src/main/java/org/apache/ignite/internal/rest/api/deployment/UnitEntry.java
@@ -0,0 +1,189 @@
+/*
+ * 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.rest.api.deployment;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonGetter;
+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 io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.media.Schema.RequiredMode;
+import java.util.List;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFile;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFolder;
+
+/**
+ * Represents an entry in a deployment unit's content structure for REST API 
responses.
+ *
+ * <p>A unit entry can be either a {@link UnitFile} or a {@link UnitFolder}.
+ * This interface is used for JSON serialization/deserialization in the REST 
API,
+ * with polymorphic type handling based on the "type" property.
+ *
+ * <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.")
+@JsonTypeInfo(
+        use = JsonTypeInfo.Id.NAME,
+        include = JsonTypeInfo.As.PROPERTY,
+        property = "type")
+@JsonSubTypes({
+        @Type(value = UnitFile.class, name = "file"),
+        @Type(value = UnitFolder.class, name = "folder")
+})
+public interface UnitEntry {
+    /**
+     * Returns the name of this entry.
+     *
+     * @return the entry name
+     */
+    @JsonGetter("name")
+    String name();
+
+    /**
+     * Returns the size of this entry in bytes.
+     *
+     * <p>For files, this is the actual file size. For folders, this is the sum
+     * of all children sizes (recursive).
+     *
+     * @return the entry size in bytes
+     */
+    @JsonGetter("size")
+    long size();
+
+    /**
+     * Represents a file entry in a deployment unit for REST API responses.
+     *
+     * <p>This nested class implements {@link UnitEntry} and is serialized with
+     * a "type": "file" discriminator in JSON responses.
+     */
+    @Schema(description = "Unit content file.")
+    public class UnitFile implements UnitEntry {
+        @Schema(description = "Unit content file name.", requiredMode = 
RequiredMode.REQUIRED)
+        private final String name;
+
+        @Schema(description = "Unit content file size in bytes.", requiredMode 
= RequiredMode.REQUIRED)
+        private final long size;
+
+        /**
+         * Creates a new file entry for JSON deserialization.
+         *
+         * @param name the file name
+         * @param size the file size in bytes
+         */
+        @JsonCreator
+        public UnitFile(
+                @JsonProperty("name") String name,
+                @JsonProperty("size") long size
+        ) {
+            this.name = name;
+            this.size = size;
+        }
+
+        /**
+         * Returns the name of this file.
+         *
+         * @return the file name
+         */
+        @JsonGetter("name")
+        @Override
+        public String name() {
+            return name;
+        }
+
+        /**
+         * Returns the size of this file in bytes.
+         *
+         * @return the file size in bytes
+         */
+        @JsonGetter("size")
+        @Override
+        public long size() {
+            return size;
+        }
+    }
+
+    /**
+     * Represents a folder (directory) entry in a deployment unit for REST API 
responses.
+     *
+     * <p>This nested class implements {@link UnitEntry} and is serialized with
+     * a "type": "folder" discriminator in JSON responses. It contains a list 
of
+     * children entries, which can be either files or other folders, creating a
+     * hierarchical structure.
+     *
+     * <p>The size of a folder is calculated as the sum of all its children's 
sizes.
+     */
+    @Schema(description = "Unit content folder.")
+    public class UnitFolder implements UnitEntry {
+        @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;
+
+        /**
+         * Creates a new folder entry for JSON deserialization.
+         *
+         * @param name the folder name
+         * @param children the list of children entries
+         */
+        @JsonCreator
+        public UnitFolder(
+                @JsonProperty("name") String name,
+                @JsonProperty("children") List<UnitEntry> children
+        ) {
+            this.name = name;
+            this.children = children;
+        }
+
+        /**
+         * Returns the name of this folder.
+         *
+         * @return the folder name
+         */
+        @JsonGetter("name")
+        @Override
+        public String name() {
+            return name;
+        }
+
+        /**
+         * Returns the total size of this folder in bytes.
+         *
+         * <p>The size is calculated as the sum of all children sizes.
+         *
+         * @return the total folder size in bytes
+         */
+        @Override
+        public long size() {
+            return children.stream().mapToLong(UnitEntry::size).sum();
+        }
+
+        /**
+         * Returns the list of children entries in this folder.
+         *
+         * @return the list of children entries
+         */
+        @JsonGetter("children")
+        public List<UnitEntry> children() {
+            return children;
+        }
+    }
+}
diff --git 
a/modules/rest/src/integrationTest/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementControllerTest.java
 
b/modules/rest/src/integrationTest/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementControllerTest.java
index 16dc06fce3a..62639bc9681 100644
--- 
a/modules/rest/src/integrationTest/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementControllerTest.java
+++ 
b/modules/rest/src/integrationTest/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementControllerTest.java
@@ -59,6 +59,9 @@ import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import org.apache.ignite.internal.ClusterConfiguration;
 import org.apache.ignite.internal.ClusterPerClassIntegrationTest;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFile;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry.UnitFolder;
 import org.apache.ignite.internal.rest.api.deployment.UnitStatus;
 import org.apache.ignite.internal.rest.api.deployment.UnitVersionStatus;
 import org.junit.jupiter.api.AfterEach;
@@ -283,6 +286,45 @@ public class DeploymentManagementControllerTest extends 
ClusterPerClassIntegrati
         );
     }
 
+    @Test
+    public void testUnitContent() {
+        String id = UNIT_ID;
+        String version = "1.1.1";
+
+        assertThat(deploy(id, version, false, smallFile, zipFile), 
hasStatus(OK));
+
+        awaitDeployedStatus(id, version);
+
+        UnitFolder folder = folder(id, version);
+
+        Path workDir0 = CLUSTER.nodeWorkDir(0);
+        Path nodeUnitDirectory = 
workDir0.resolve("deployment").resolve(id).resolve(version);
+
+        for (UnitEntry child : folder.children()) {
+            verifyEntry(child, nodeUnitDirectory);
+        }
+    }
+
+    private static void verifyEntry(UnitEntry entry, Path currentDir) {
+        try {
+            if (entry instanceof UnitFile) {
+                UnitFile file = (UnitFile) entry;
+                Path filePath = currentDir.resolve(file.name());
+                assertTrue(Files.exists(filePath));
+                assertThat(Files.size(filePath), is(file.size()));
+            } else if (entry instanceof UnitFolder) {
+                Path dir = currentDir.resolve(entry.name());
+                for (UnitEntry child : ((UnitFolder) entry).children()) {
+                    verifyEntry(child, dir);
+                }
+            } else {
+                fail(new IllegalStateException("Unit entry type not 
supported."));
+            }
+        } catch (IOException e) {
+            fail(e);
+        }
+    }
+
     private void awaitDeployedStatus(String id, String... versions) {
         await().untilAsserted(() -> {
             MutableHttpRequest<Object> get = HttpRequest.GET("cluster/units");
@@ -336,4 +378,10 @@ public class DeploymentManagementControllerTest extends 
ClusterPerClassIntegrati
 
         return client.toBlocking().retrieve(get, 
Argument.listOf(UnitStatus.class));
     }
+
+    private UnitFolder folder(String id, String version) {
+        MutableHttpRequest<Object> get = 
HttpRequest.GET("node/units/structure/" + id + "/" + version);
+
+        return client.toBlocking().retrieve(get, UnitFolder.class);
+    }
 }
diff --git 
a/modules/rest/src/main/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementController.java
 
b/modules/rest/src/main/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementController.java
index e0d810c03aa..ace212efb69 100644
--- 
a/modules/rest/src/main/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementController.java
+++ 
b/modules/rest/src/main/java/org/apache/ignite/internal/rest/deployment/DeploymentManagementController.java
@@ -36,6 +36,8 @@ import org.apache.ignite.deployment.version.Version;
 import org.apache.ignite.internal.deployunit.IgniteDeployment;
 import org.apache.ignite.internal.deployunit.NodesToDeploy;
 import org.apache.ignite.internal.deployunit.UnitStatuses;
+import org.apache.ignite.internal.deployunit.structure.UnitFile;
+import org.apache.ignite.internal.deployunit.structure.UnitFolder;
 import org.apache.ignite.internal.deployunit.tempstorage.TempStorage;
 import org.apache.ignite.internal.deployunit.tempstorage.TempStorageProvider;
 import org.apache.ignite.internal.logger.IgniteLogger;
@@ -44,6 +46,7 @@ import org.apache.ignite.internal.rest.ResourceHolder;
 import org.apache.ignite.internal.rest.api.deployment.DeploymentCodeApi;
 import org.apache.ignite.internal.rest.api.deployment.DeploymentStatus;
 import org.apache.ignite.internal.rest.api.deployment.InitialDeployMode;
+import org.apache.ignite.internal.rest.api.deployment.UnitEntry;
 import org.apache.ignite.internal.rest.api.deployment.UnitStatus;
 import org.apache.ignite.internal.rest.api.deployment.UnitVersionStatus;
 import org.jetbrains.annotations.Nullable;
@@ -251,6 +254,28 @@ public class DeploymentManagementController implements 
DeploymentCodeApi, Resour
         return DeploymentStatus.valueOf(status.name());
     }
 
+    @Override
+    public CompletableFuture<UnitEntry.UnitFolder> unitStructure(String 
unitId, String unitVersion) {
+        return deployment.nodeUnitFileStructure(unitId, 
parseVersion(unitVersion)).thenApply(DeploymentManagementController::toDto);
+    }
+
+    private static UnitEntry.UnitFolder toDto(UnitFolder unitFolder) {
+        return new UnitEntry.UnitFolder(
+                unitFolder.name(),
+                unitFolder.children().stream()
+                        .map(DeploymentManagementController::toDto)
+                        .collect(Collectors.toList())
+        );
+    }
+
+    private static UnitEntry 
toDto(org.apache.ignite.internal.deployunit.structure.UnitEntry entry) {
+        if (entry instanceof UnitFile) {
+            return new UnitEntry.UnitFile(entry.name(), entry.size());
+        } else {
+            return toDto((UnitFolder) entry);
+        }
+    }
+
     @Override
     public void cleanResources() {
         deployment = null;


Reply via email to