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 45d55f8d2af IGNITE-27200 Add REST for access deployment structure
(#7262)
45d55f8d2af is described below
commit 45d55f8d2af72c6b0d65cb2cccb6ecce253df80b
Author: Mikhail <[email protected]>
AuthorDate: Tue Dec 23 18:34:30 2025 +0300
IGNITE-27200 Add REST for access deployment structure (#7262)
---
.../deployunit/util/DummyIgniteDeployment.java | 6 +
.../ignite/internal/deployment/DeployFiles.java | 3 +-
.../apache/ignite/internal/deployment/Unit.java | 45 ++++-
.../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 ++++
.../rest/api/deployment/DeploymentCodeApi.java | 25 +++
.../internal/rest/api/deployment/UnitEntry.java | 189 +++++++++++++++++++++
.../DeploymentManagementControllerTest.java | 48 ++++++
.../deployment/DeploymentManagementController.java | 25 +++
15 files changed, 744 insertions(+), 7 deletions(-)
diff --git
a/modules/code-deployment-classloader/src/test/java/org/apache/ignite/internal/deployunit/util/DummyIgniteDeployment.java
b/modules/code-deployment-classloader/src/test/java/org/apache/ignite/internal/deployunit/util/DummyIgniteDeployment.java
index f23b65b6a32..1ff8ab0bea6 100644
---
a/modules/code-deployment-classloader/src/test/java/org/apache/ignite/internal/deployunit/util/DummyIgniteDeployment.java
+++
b/modules/code-deployment-classloader/src/test/java/org/apache/ignite/internal/deployunit/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/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..a85dca7e828 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,42 @@ 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 {
+ for (UnitEntry child : folder.children()) {
+ processEntry(child, 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/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 f1cd0d82a9d..8a420b7c64b 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
@@ -60,6 +60,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;
@@ -303,6 +306,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");
@@ -356,4 +398,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 d0fac3af2d3..e257531c966 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;
@@ -262,6 +265,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;