Repository: kestra-io/kestra (java)

Issue:
Add a task allowing to download, upload or delete specific Namespace Files
Possible usage syntax:

```yaml
id: python
namespace: dev

tasks:
  - id: scripts
    type: io.kestra.plugin.core.flow.WorkingDirectory
    tasks:
      - id: fetch_scripts_from_parent_namespace
        type: io.kestra.plugin.core.namespace.Files
        action: DOWNLOAD # default; alternative options in the Enum property: UPLOAD, DELETE
        namespace: dev.shared
        files: # list of RegEx strings or specific file paths
          - "project1/*.py"

      - id: python
        type: io.kestra.plugin.scripts.python.Commands
        warningOnStdErr: false
        containerImage: ghcr.io/kestra-io/pydata:latest
        commands:
          - python project1/etl.py
```

On EE, we ensure that users can only download/upload/delete files from a different namespace if there are no restrictions in Allowed Namespaces.


Pull request:
feat(core): namespace files tasks
close #2461

Patch:
diff --git a/core/src/main/java/io/kestra/core/runners/NamespaceFilesService.java b/core/src/main/java/io/kestra/core/runners/NamespaceFilesService.java
index 9708f626c..196c14f87 100644
--- a/core/src/main/java/io/kestra/core/runners/NamespaceFilesService.java
+++ b/core/src/main/java/io/kestra/core/runners/NamespaceFilesService.java
@@ -1,7 +1,6 @@
 package io.kestra.core.runners;
 
 import io.kestra.core.models.tasks.NamespaceFiles;
-import io.kestra.core.storages.FileAttributes;
 import io.kestra.core.storages.StorageContext;
 import io.kestra.core.storages.StorageInterface;
 import io.micronaut.core.annotation.Nullable;
@@ -9,18 +8,20 @@ import jakarta.inject.Inject;
 import jakarta.inject.Singleton;
 import lombok.extern.slf4j.Slf4j;
 
-import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URI;
-import java.nio.file.*;
-import java.util.ArrayList;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
 import java.util.stream.Collectors;
 
-import static io.kestra.core.utils.Rethrow.*;
+import static io.kestra.core.utils.Rethrow.throwConsumer;
+import static io.kestra.core.utils.Rethrow.throwPredicate;
 import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
 
 @Singleton
@@ -112,6 +113,10 @@ public class NamespaceFilesService {
         return storageInterface.get(tenantId, uri(namespace, path));
     }
 
+    public static URI toNamespacedStorageUri(String namespace, @Nullable URI relativePath) {
+        return URI.create("kestra://" + StorageContext.namespaceFilePrefix(namespace) + Optional.ofNullable(relativePath).map(URI::getPath).orElse("/"));
+    }
+
     private void copy(String tenantId, String namespace, Path basePath, List<URI> files) throws IOException {
         files
             .forEach(throwConsumer(f -> {
diff --git a/core/src/main/java/io/kestra/plugin/core/namespace/DeleteFiles.java b/core/src/main/java/io/kestra/plugin/core/namespace/DeleteFiles.java
new file mode 100644
index 000000000..1f84565a3
--- /dev/null
+++ b/core/src/main/java/io/kestra/plugin/core/namespace/DeleteFiles.java
@@ -0,0 +1,102 @@
+package io.kestra.plugin.core.namespace;
+
+import io.kestra.core.models.annotations.Example;
+import io.kestra.core.models.annotations.Plugin;
+import io.kestra.core.models.annotations.PluginProperty;
+import io.kestra.core.models.executions.metrics.Counter;
+import io.kestra.core.models.tasks.RunnableTask;
+import io.kestra.core.models.tasks.Task;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.services.FlowService;
+import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.Rethrow;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+import org.codehaus.commons.nullanalysis.NotNull;
+import org.slf4j.Logger;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.regex.Pattern;
+
+import static io.kestra.core.runners.NamespaceFilesService.toNamespacedStorageUri;
+
+@SuperBuilder
+@Getter
+@NoArgsConstructor
+@Schema(
+    title = "Delete one or multiple files from your namespace files."
+)
+@Plugin(
+    examples = {
+        @Example(
+            title = "Delete namespace files.",
+            full = true,
+            code = {
+                "id: namespace-file-delete",
+                "namespace: io.kestra.tests",
+                "",
+                "tasks:",
+                    "    - id: delete",
+                    "    type: io.kestra.plugin.core.namespace.DeleteFiles",
+                    "    namespace: tutorial",
+                    "    files:",
+                    "    - \\.upl"
+            }
+        )
+    }
+)
+public class DeleteFiles extends Task implements RunnableTask<DeleteFiles.Output> {
+    @NotNull
+    @Schema(
+        title = "The namespace where you want to apply the action."
+    )
+    @PluginProperty(dynamic = true)
+    private String namespace;
+
+    @NotNull
+    @Schema(
+        title = "A list of files from the given namespace.",
+        description = "Must be a specific uri for upload, but can be an uri or a regex for delete and download."
+    )
+    @PluginProperty(dynamic = true)
+    private List<String> files;
+
+    @Override
+    public Output run(RunContext runContext) throws Exception {
+        Logger logger = runContext.logger();
+        String renderedNamespace = runContext.render(namespace);
+        // Check if namespace is allowed
+        RunContext.FlowInfo flowInfo = runContext.flowInfo();
+        FlowService flowService = runContext.getApplicationContext().getBean(FlowService.class);
+        flowService.checkAllowedNamespace(flowInfo.tenantId(), renderedNamespace, flowInfo.tenantId(), flowInfo.namespace());
+
+        // Access to files
+        StorageInterface storageInterface = runContext.getApplicationContext().getBean(StorageInterface.class);
+        URI baseNamespaceFilesUri = toNamespacedStorageUri(renderedNamespace, null);
+
+        List<Pattern> patterns = runContext.render(files).stream().map(Pattern::compile).toList();
+        AtomicInteger count = new AtomicInteger();
+        storageInterface.allByPrefix(flowInfo.tenantId(), baseNamespaceFilesUri, false).forEach(Rethrow.throwConsumer(uri -> {
+            if (patterns.stream().anyMatch(p -> p.matcher(uri.getPath()).find())) {
+                storageInterface.delete(flowInfo.tenantId(), uri);
+                logger.debug(String.format("Deleted %s", uri));
+                count.getAndIncrement();
+            }
+        }));
+
+        runContext.metric(Counter.of("deleted", count.get()));
+        return Output.builder().build();
+    }
+
+    @Builder
+    @Getter
+    public static class Output implements io.kestra.core.models.tasks.Output {
+        private final Map<String, URI> files;
+    }
+}
diff --git a/core/src/main/java/io/kestra/plugin/core/namespace/DownloadFiles.java b/core/src/main/java/io/kestra/plugin/core/namespace/DownloadFiles.java
new file mode 100644
index 000000000..583b69309
--- /dev/null
+++ b/core/src/main/java/io/kestra/plugin/core/namespace/DownloadFiles.java
@@ -0,0 +1,108 @@
+package io.kestra.plugin.core.namespace;
+
+import io.kestra.core.models.annotations.Example;
+import io.kestra.core.models.annotations.Plugin;
+import io.kestra.core.models.annotations.PluginProperty;
+import io.kestra.core.models.executions.metrics.Counter;
+import io.kestra.core.models.tasks.RunnableTask;
+import io.kestra.core.models.tasks.Task;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.services.FlowService;
+import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.Rethrow;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+import lombok.extern.slf4j.Slf4j;
+import org.codehaus.commons.nullanalysis.NotNull;
+import org.slf4j.Logger;
+
+import java.io.InputStream;
+import java.net.URI;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+import static io.kestra.core.runners.NamespaceFilesService.toNamespacedStorageUri;
+
+@Slf4j
+@SuperBuilder
+@Getter
+@NoArgsConstructor
+@Schema(
+    title = "Download one or multiple files from your namespace files."
+)
+@Plugin(
+    examples = {
+        @Example(
+            title = "Download namespace files.",
+            full = true,
+            code = {
+                "id: namespace-file-download",
+                "namespace: io.kestra.tests",
+                "",
+                "tasks:",
+                "    - id: download",
+                "    type: io.kestra.plugin.core.namespace.DownloadFiles",
+                "    namespace: tutorial",
+                "    files:",
+                "    - input.txt"
+            }
+        )
+    }
+)
+public class DownloadFiles extends Task implements RunnableTask<DownloadFiles.Output> {
+    @NotNull
+    @Schema(
+        title = "The namespace where you want to apply the action."
+    )
+    @PluginProperty(dynamic = true)
+    private String namespace;
+
+    @NotNull
+    @Schema(
+        title = "A list of files from the given namespace.",
+        description = "Must be a specific uri for upload, but can be an uri or a regex for delete and download."
+    )
+    @PluginProperty(dynamic = true)
+    private List<String> files;
+
+    @Override
+    public Output run(RunContext runContext) throws Exception {
+        Logger logger = runContext.logger();
+        String renderedNamespace = runContext.render(namespace);
+        // Check if namespace is allowed
+        RunContext.FlowInfo flowInfo = runContext.flowInfo();
+        FlowService flowService = runContext.getApplicationContext().getBean(FlowService.class);
+        flowService.checkAllowedNamespace(flowInfo.tenantId(), renderedNamespace, flowInfo.tenantId(), flowInfo.namespace());
+
+        StorageInterface storageInterface = runContext.getApplicationContext().getBean(StorageInterface.class);
+        URI baseNamespaceFilesUri = toNamespacedStorageUri(renderedNamespace, null);
+
+        List<Pattern> patterns = files.stream().map(Pattern::compile).toList();
+
+        Map<String, URI> downloaded = new HashMap<>();
+
+        storageInterface.allByPrefix(flowInfo.tenantId(), baseNamespaceFilesUri, false).forEach(Rethrow.throwConsumer(uri -> {
+            if (patterns.stream().anyMatch(p -> p.matcher(uri.getPath()).find())) {
+                try (InputStream inputStream = storageInterface.get(flowInfo.tenantId(), uri)) {
+                    String filename = uri.toString().split("/_files/")[1];
+                    downloaded.put(filename, runContext.storage().putFile(inputStream, filename));
+                    logger.debug(String.format("Downloaded %s", filename));
+                }
+            }
+        }));
+        runContext.metric(Counter.of("downloaded", downloaded.size()));
+        return Output.builder().files(downloaded).build();
+    }
+
+    @Builder
+    @Getter
+    public static class Output implements io.kestra.core.models.tasks.Output {
+        private final Map<String, URI> files;
+    }
+
+}
diff --git a/core/src/main/java/io/kestra/plugin/core/namespace/UploadFiles.java b/core/src/main/java/io/kestra/plugin/core/namespace/UploadFiles.java
new file mode 100644
index 000000000..eb3a728a6
--- /dev/null
+++ b/core/src/main/java/io/kestra/plugin/core/namespace/UploadFiles.java
@@ -0,0 +1,219 @@
+package io.kestra.plugin.core.namespace;
+
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.models.annotations.Example;
+import io.kestra.core.models.annotations.Plugin;
+import io.kestra.core.models.annotations.PluginProperty;
+import io.kestra.core.models.tasks.RunnableTask;
+import io.kestra.core.models.tasks.Task;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.services.FlowService;
+import io.kestra.core.storages.StorageInterface;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.experimental.SuperBuilder;
+import org.codehaus.commons.nullanalysis.NotNull;
+import org.slf4j.Logger;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+import static io.kestra.core.runners.NamespaceFilesService.toNamespacedStorageUri;
+
+@SuperBuilder(toBuilder = true)
+@Getter
+@NoArgsConstructor
+@Schema(
+    title = "Upload one or multiple files to your namespace files.",
+    description = "Files can be upload using a regex or a specific path and can also be renamed."
+)
+@Plugin(
+    examples = {
+        @Example(
+            title = "Upload namespace file using a Map as input files",
+            full = true,
+            code = {
+                "id: namespace-file-upload",
+                "namespace: io.kestra.tests",
+                "",
+                "inputs:",
+                "    - id: file",
+                "    type: FILE",
+                "",
+                "tasks:",
+                    "    - id: upload-map",
+                    "    type: io.kestra.plugin.core.namespace.UploadFiles",
+                    "    files:",
+                    "    \"/upload_map/input.txt\": \"{{ inputs.file }}\"",
+                    "    namespace: io.kestra.tests"
+            }
+        ),
+        @Example(
+            title = "Upload namespace file using a List of String as input files",
+            full = true,
+            code = {
+                "id: namespace-file-upload",
+                "namespace: io.kestra.tests",
+                "",
+                "tasks:",
+                "    - id: upload-list",
+                "    type: io.kestra.plugin.core.namespace.UploadFiles",
+                "    destination: \"/upload_list\"",
+                "    files:",
+                "    - \"{{ inputs.file }}\"",
+                "        - hell",
+                "    namespace: tutorial"
+            }
+        )
+    }
+)
+public class UploadFiles extends Task implements RunnableTask<UploadFiles.Output> {
+    @NotNull
+    @Schema(
+        title = "The namespace where the file will be upload."
+    )
+    @PluginProperty(dynamic = true)
+    private String namespace;
+
+    @NotNull
+    @Schema(
+        title = "A list of files from the given namespace.",
+        description = "Can be a list of String, containing both a regex or a URI. Providing a list required to specify a `destination`.\n" +
+                    "Can also be a Map where you can give a specific destination path for a specific URI, useful if you need to rename a file.",
+        anyOf = {List.class, Map.class}
+    )
+    @PluginProperty(dynamic = true)
+    private Object files;
+
+    @Schema(
+        title = "The destination folder.",
+        description = "Required when providing a list of files."
+    )
+    @PluginProperty(dynamic = true)
+    private String destination;
+
+    @Builder.Default
+
+    @Schema(
+        title = "Action to execute when uploading a file that already exists.",
+        description = "Can be OVERWRITE, ERROR or SKIP."
+    )
+    private ConflictAction conflict = ConflictAction.OVERWRITE;
+
+    @Override
+    public UploadFiles.Output run(RunContext runContext) throws Exception {
+        Logger logger = runContext.logger();
+        String renderedNamespace = runContext.render(namespace);
+        String renderedDestination = runContext.render(destination);
+        // Check if namespace is allowed
+        RunContext.FlowInfo flowInfo = runContext.flowInfo();
+        FlowService flowService = runContext.getApplicationContext().getBean(FlowService.class);
+        flowService.checkAllowedNamespace(flowInfo.tenantId(), renderedNamespace, flowInfo.tenantId(), flowInfo.namespace());
+
+        StorageInterface storageInterface = runContext.getApplicationContext().getBean(StorageInterface.class);
+
+        if (files instanceof List filesList) {
+            if (renderedDestination == null) {
+                throw new RuntimeException("Destination must be set when providing a List for the `files` property.");
+            }
+            filesList = runContext.render((List<String>) filesList);
+
+            final List<String> regexs = new ArrayList<>();
+
+            // First handle string that are full URI
+            ((List<String>) filesList).forEach(path -> {
+                if (storageInterface.exists(flowInfo.tenantId(), URI.create(path))) {
+                    String newFilePath = null;
+                    try {
+                        newFilePath = buildPath(renderedDestination, storageInterface.getAttributes(flowInfo.tenantId(), URI.create(path)).getFileName());
+                        storeNewFile(logger, runContext, storageInterface, flowInfo.tenantId(), newFilePath, storageInterface.get(flowInfo.tenantId(), URI.create(path)));
+                    } catch (IOException | IllegalVariableEvaluationException e) {
+                        throw new RuntimeException(e);
+                    }
+                } else {
+                    regexs.add(path);
+                }
+            });
+
+            // check for file in current tempDir that match regexs
+            List<Pattern> patterns = regexs.stream().map(Pattern::compile).toList();
+            for (File file : Objects.requireNonNull(runContext.tempDir().toFile().listFiles())) {
+                if (patterns.stream().anyMatch(p -> p.matcher(file.toURI().getPath()).find())) {
+                    String newFilePath = buildPath(renderedDestination, file.getName());
+                    storeNewFile(logger, runContext, storageInterface, flowInfo.tenantId(), newFilePath, new FileInputStream(file));
+                }
+            }
+        } else if (files instanceof Map map) {
+            // Using a Map for the `files` property, there must be only URI
+            Map<String, Object> renderedMap = runContext.render((Map<String, Object>) map);
+            renderedMap.forEach((key, value) -> {
+                if (key instanceof String keyString && value instanceof String valueString) {
+                    URI toUpload = URI.create(valueString);
+                    if (storageInterface.exists(flowInfo.tenantId(), toUpload)) {
+                        try {
+                            storeNewFile(logger, runContext, storageInterface, flowInfo.tenantId(), keyString, storageInterface.get(flowInfo.tenantId(), toUpload));
+                        } catch (IOException | IllegalVariableEvaluationException e) {
+                            throw new RuntimeException(e);
+                        }
+                    }
+                } else {
+                    throw new IllegalArgumentException("files must be a List<String> or a Map<String, String>");
+                }
+            });
+        }
+
+        return Output.builder().build();
+    }
+
+    private String buildPath(String destination, String filename) {
+        if (!destination.startsWith("/")) {
+            return "/" + String.join("/", destination, filename);
+        }
+        return String.join("/", destination, filename);
+    }
+
+    private void storeNewFile(Logger logger, RunContext runContext, StorageInterface storageInterface, String tenantId, String filePath, InputStream fileContent) throws IOException, IllegalVariableEvaluationException {
+        String renderedNamespace = runContext.render(namespace);
+        URI newFileURI = toNamespacedStorageUri(
+            renderedNamespace,
+            URI.create(filePath)
+        );
+        if (!conflict.equals(ConflictAction.OVERWRITE) && storageInterface.exists(tenantId, newFileURI)) {
+            if (conflict.equals(ConflictAction.ERROR)) {
+                throw new IOException(String.format("File %s already exists and conflict is set to %s", filePath, ConflictAction.ERROR));
+            }
+            logger.debug(String.format("File %s already exists and conflict is set to %s, skipping", filePath, ConflictAction.ERROR));
+            return;
+        }
+
+        storageInterface.put(
+            tenantId,
+            newFileURI,
+            fileContent
+        );
+        logger.debug(String.format("File %s created", filePath));
+    }
+
+    @Builder
+    @Getter
+    public static class Output implements io.kestra.core.models.tasks.Output {
+        private final Map<String, URI> files;
+    }
+
+    public enum ConflictAction {
+        OVERWRITE,
+        ERROR,
+        SKIP
+    }
+
+}
diff --git a/core/src/main/java/io/kestra/plugin/core/namespace/package-info.java b/core/src/main/java/io/kestra/plugin/core/namespace/package-info.java
new file mode 100644
index 000000000..331049d0b
--- /dev/null
+++ b/core/src/main/java/io/kestra/plugin/core/namespace/package-info.java
@@ -0,0 +1,4 @@
+@PluginSubGroup(categories = PluginSubGroup.PluginCategory.CORE)
+package io.kestra.plugin.core.namespace;
+
+import io.kestra.core.models.annotations.PluginSubGroup;
\ No newline at end of file
diff --git a/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DeleteFiles.svg b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DeleteFiles.svg
new file mode 100644
index 000000000..af8647c36
--- /dev/null
+++ b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DeleteFiles.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.8973 2.18179C3.44231 2.18179 3.00596 2.36254 2.68423 2.68426C2.36251 3.00599 2.18176 3.44234 2.18176 3.89733V5.38841H3.78507V3.7851H5.38838V2.18179H3.8973ZM6.99169 2.18179V3.7851H9.39665V2.18179H6.99169ZM11 2.18179V3.7851H12.6033V5.38841H14.2066V3.89733C14.2066 2.95138 13.437 2.18179 12.491 2.18179H11ZM2.18176 6.99172V9.39668H3.78507V6.99172H2.18176ZM8.595 6.99172C7.70516 6.99172 6.99169 7.70519 6.99169 8.59503C6.99169 10.4629 6.99169 14.2066 6.99169 14.2066C6.99169 14.2066 10.6552 14.2066 12.491 14.2066C12.946 14.2066 13.3824 14.0259 13.7041 13.7041C14.0258 13.3824 14.2066 12.9461 14.2066 12.4911C14.2066 10.6553 14.2066 6.99172 14.2066 6.99172C14.2066 6.99172 10.4629 6.99172 8.595 6.99172ZM15.8099 6.99172V8.59503H17.4132V10.1983H19.0165V8.59503C19.0165 7.70519 18.303 6.99172 17.4132 6.99172H15.8099ZM8.595 8.59503H12.6033V12.6033H8.595V8.59503ZM2.18176 11V12.4911C2.18176 13.437 2.95135 14.2066 3.8973 14.2066H5.38838V12.6033H3.78507V11H2.18176ZM17.4132 11.8016V14.2066H19.0165V11.8016H17.4132ZM6.99169 15.8099V17.4132C6.99169 18.3031 7.70516 19.0165 8.595 19.0165H10.1983V17.4132H8.595V15.8099H6.99169ZM11.8016 17.4132V19.0165H14.2066V17.4132H11.8016Z" fill="currentColor"/>
+<path d="M19.87 18.3801L22 16.2601L20.58 14.8401L18.46 16.9701L16.34 14.8401L14.92 16.2601L17.05 18.3801L14.92 20.5001L16.34 21.9201L18.46 19.7901L20.58 21.9201L22 20.5001L19.87 18.3801Z" fill="currentColor"/>
+</svg>
diff --git a/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DownloadFiles.svg b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DownloadFiles.svg
new file mode 100644
index 000000000..2d0d629fa
--- /dev/null
+++ b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.DownloadFiles.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.89718 3.18179C3.44219 3.18179 3.00584 3.36254 2.68411 3.68426C2.36238 4.00599 2.18164 4.44234 2.18164 4.89733V6.38841H3.78495V4.7851H5.38826V3.18179H3.89718ZM6.99157 3.18179V4.7851H9.39653V3.18179H6.99157ZM10.9998 3.18179V4.7851H12.6031V6.38841H14.2065V4.89733C14.2065 3.95138 13.4369 3.18179 12.4909 3.18179H10.9998ZM2.18164 7.99172V10.3967H3.78495V7.99172H2.18164ZM8.59487 7.99172C7.70504 7.99172 6.99157 8.70519 6.99157 9.59503C6.99157 11.4629 6.99157 15.2066 6.99157 15.2066C6.99157 15.2066 10.6551 15.2066 12.4909 15.2066C12.9459 15.2066 13.3823 15.0259 13.704 14.7041C14.0257 14.3824 14.2065 13.9461 14.2065 13.4911C14.2065 11.6553 14.2065 7.99172 14.2065 7.99172C14.2065 7.99172 10.4627 7.99172 8.59487 7.99172ZM15.8098 7.99172V9.59503H17.4131V11.1983H19.0164V9.59503C19.0164 8.70519 18.3029 7.99172 17.4131 7.99172H15.8098ZM8.59487 9.59503H12.6031V13.6033H8.59487V9.59503ZM2.18164 12V13.4911C2.18164 14.437 2.95123 15.2066 3.89718 15.2066H5.38826V13.6033H3.78495V12H2.18164ZM6.99157 16.8099V18.4132C6.99157 19.3031 7.70504 20.0165 8.59487 20.0165H10.1982V18.4132H8.59487V16.8099H6.99157ZM11.8015 18.4132V20.0165H14.0527V18.4132H11.8015Z" fill="currentColor"/>
+<path d="M21.1097 17.0165L18.1097 20.0165L15.1097 17.0165H17.1097V13.0165H19.1097V17.0165H21.1097Z" fill="currentColor"/>
+</svg>
diff --git a/core/src/main/resources/icons/io.kestra.plugin.core.namespace.UploadFiles.svg b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.UploadFiles.svg
new file mode 100644
index 000000000..de900beeb
--- /dev/null
+++ b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.UploadFiles.svg
@@ -0,0 +1,4 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+<path d="M3.89718 3.18179C3.44219 3.18179 3.00584 3.36254 2.68411 3.68426C2.36238 4.00599 2.18164 4.44234 2.18164 4.89733V6.38841H3.78495V4.7851H5.38826V3.18179H3.89718ZM6.99157 3.18179V4.7851H9.39653V3.18179H6.99157ZM10.9998 3.18179V4.7851H12.6031V6.38841H14.2065V4.89733C14.2065 3.95138 13.4369 3.18179 12.4909 3.18179H10.9998ZM2.18164 7.99172V10.3967H3.78495V7.99172H2.18164ZM8.59487 7.99172C7.70504 7.99172 6.99157 8.70519 6.99157 9.59503C6.99157 11.4629 6.99157 15.2066 6.99157 15.2066C6.99157 15.2066 10.6551 15.2066 12.4909 15.2066C12.9459 15.2066 13.3823 15.0259 13.704 14.7041C14.0257 14.3824 14.2065 13.9461 14.2065 13.4911C14.2065 11.6553 14.2065 7.99172 14.2065 7.99172C14.2065 7.99172 10.4627 7.99172 8.59487 7.99172ZM15.8098 7.99172V9.59503H17.4131V11.1983H19.0164V9.59503C19.0164 8.70519 18.3029 7.99172 17.4131 7.99172H15.8098ZM8.59487 9.59503H12.6031V13.6033H8.59487V9.59503ZM2.18164 12V13.4911C2.18164 14.437 2.95123 15.2066 3.89718 15.2066H5.38826V13.6033H3.78495V12H2.18164ZM6.99157 16.8099V18.4132C6.99157 19.3031 7.70504 20.0165 8.59487 20.0165H10.1982V18.4132H8.59487V16.8099H6.99157ZM11.8015 18.4132V20.0165H14.2065V18.4132H11.8015Z" fill="currentColor"/>
+<path d="M15.1809 16L18.1809 13L21.1809 16H19.1809V20H17.1809V16H15.1809Z" fill="currentColor"/>
+</svg>
diff --git a/core/src/main/resources/icons/io.kestra.plugin.core.namespace.svg b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.svg
new file mode 100644
index 000000000..27180236b
--- /dev/null
+++ b/core/src/main/resources/icons/io.kestra.plugin.core.namespace.svg
@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
+    <path d="M3.14,1A2.14,2.14 0 0,0 1,3.14V5H3V3H5V1H3.14M7,1V3H10V1H7M12,1V3H14V5H16V3.14C16,1.96 15.04,1 13.86,1H12M1,7V10H3V7H1M9,7C7.89,7 7,7.89 7,9C7,11.33 7,16 7,16C7,16 11.57,16 13.86,16A2.14,2.14 0 0,0 16,13.86C16,11.57 16,7 16,7C16,7 11.33,7 9,7M18,7V9H20V11H22V9C22,7.89 21.11,7 20,7H18M9,9H14V14H9V9M1,12V13.86C1,15.04 1.96,16 3.14,16H5V14H3V12H1M20,13V16H22V13H20M7,18V20C7,21.11 7.89,22 9,22H11V20H9V18H7M20,18V20H18V22H20C21.11,22 22,21.11 22,20V18H20M13,20V22H16V20H13Z" fill="currentColor"/>
+</svg>
\ No newline at end of file
diff --git a/ui/src/components/inputs/MonacoEditor.vue b/ui/src/components/inputs/MonacoEditor.vue
index ad8a23d5e..1d1282929 100644
--- a/ui/src/components/inputs/MonacoEditor.vue
+++ b/ui/src/components/inputs/MonacoEditor.vue
@@ -589,6 +589,8 @@
                         }
                     }
                 });
+
+                setTimeout(() => monaco.editor.remeasureFonts(), 1)
                 this.$emit("editorDidMount", this.editor);
             },
             async changeTab(pathOrName, valueSupplier, useModelCache = true) {
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java
index dfdd7a246..5c9d8fe1c 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/NamespaceFileController.java
@@ -2,7 +2,6 @@ package io.kestra.webserver.controllers.api;
 
 import io.kestra.core.services.FlowService;
 import io.kestra.core.storages.FileAttributes;
-import io.kestra.core.storages.StorageContext;
 import io.kestra.core.storages.StorageInterface;
 import io.kestra.core.tenant.TenantService;
 import io.kestra.core.utils.Rethrow;
@@ -23,12 +22,16 @@ import lombok.extern.slf4j.Slf4j;
 import java.io.*;
 import java.net.URI;
 import java.net.URISyntaxException;
-import java.util.*;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
 import java.util.regex.Pattern;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import java.util.zip.ZipOutputStream;
 
+import static io.kestra.core.runners.NamespaceFilesService.toNamespacedStorageUri;
+
 @Slf4j
 @Validated
 @Controller("/api/v1/namespaces")
@@ -242,7 +245,7 @@ public class NamespaceFileController {
 
         String pathWithoutScheme = path.getPath();
         List<String> allNamespaceFilesPaths = new ArrayList<>(storageInterface.allByPrefix(tenantService.resolveTenant(), toNamespacedStorageUri(namespace, null), true).stream()
-            .map(this.toNamespacedStorageUri(namespace, null)::relativize)
+            .map(toNamespacedStorageUri(namespace, null)::relativize)
             .map(uri -> "/" + uri.getPath())
             .toList());
 
@@ -277,10 +280,6 @@ public class NamespaceFileController {
         }
     }
 
-    private URI toNamespacedStorageUri(String namespace, @Nullable URI relativePath) {
-        return URI.create("kestra://" + StorageContext.namespaceFilePrefix(namespace) + Optional.ofNullable(relativePath).map(URI::getPath).orElse("/"));
-    }
-
     private void ensureWritableNamespaceFile(URI path) {
         forbiddenPathsGuard(path);
     }


Test patch:
diff --git a/core/src/test/java/io/kestra/plugin/core/namespace/DeleteFilesTest.java b/core/src/test/java/io/kestra/plugin/core/namespace/DeleteFilesTest.java
new file mode 100644
index 000000000..75d71a194
--- /dev/null
+++ b/core/src/test/java/io/kestra/plugin/core/namespace/DeleteFilesTest.java
@@ -0,0 +1,63 @@
+package io.kestra.plugin.core.namespace;
+
+import com.google.common.collect.ImmutableMap;
+import io.kestra.core.runners.NamespaceFilesService;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.runners.RunContextFactory;
+import io.kestra.core.storages.StorageContext;
+import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.IdUtils;
+import io.kestra.core.utils.TestsUtils;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+
+@MicronautTest
+public class DeleteFilesTest {
+    @Inject
+    StorageInterface storageInterface;
+
+    @Inject
+    NamespaceFilesService namespaceFilesService;
+
+    @Inject
+    RunContextFactory runContextFactory;
+
+    @Test
+    void delete() throws Exception {
+        String namespace = "io.kestra." + IdUtils.create();
+
+        put(namespace, "/a/b/test1.txt", "1");
+        put(namespace, "/a/b/test2.txt", "1");
+
+        DeleteFiles deleteFiles = DeleteFiles.builder()
+            .id(DeleteFiles.class.getSimpleName())
+            .type(DeleteFiles.class.getName())
+            .files(List.of("test1"))
+            .namespace("{{ inputs.namespace }}")
+            .build();
+
+        RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, deleteFiles,  ImmutableMap.of("namespace", namespace));
+
+        DeleteFiles.Output output = deleteFiles.run(runContext);
+
+        assertThat(namespaceFilesService.recursiveList(null, namespace, URI.create("/a/b/")).size(), is(1));
+
+    }
+
+    private void put(String namespace, String path, String content) throws IOException {
+        storageInterface.put(
+            null,
+            URI.create(StorageContext.namespaceFilePrefix(namespace) + path),
+            new ByteArrayInputStream(content.getBytes())
+        );
+    }
+}
diff --git a/core/src/test/java/io/kestra/plugin/core/namespace/DownloadFilesTest.java b/core/src/test/java/io/kestra/plugin/core/namespace/DownloadFilesTest.java
new file mode 100644
index 000000000..491376617
--- /dev/null
+++ b/core/src/test/java/io/kestra/plugin/core/namespace/DownloadFilesTest.java
@@ -0,0 +1,65 @@
+package io.kestra.plugin.core.namespace;
+
+import com.google.common.collect.ImmutableMap;
+import io.kestra.core.runners.NamespaceFilesService;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.runners.RunContextFactory;
+import io.kestra.core.storages.StorageContext;
+import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.IdUtils;
+import io.kestra.core.utils.TestsUtils;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.hamcrest.Matchers.notNullValue;
+
+@MicronautTest
+public class DownloadFilesTest {
+    @Inject
+    StorageInterface storageInterface;
+
+    @Inject
+    NamespaceFilesService namespaceFilesService;
+
+    @Inject
+    RunContextFactory runContextFactory;
+
+    @Test
+    void download() throws Exception {
+        String namespace = "io.kestra." + IdUtils.create();
+
+        put(namespace, "/a/b/test1.txt", "1");
+        put(namespace, "/a/b/test2.txt", "1");
+
+        DownloadFiles downloadFiles = DownloadFiles.builder()
+            .id(DownloadFiles.class.getSimpleName())
+            .type(DownloadFiles.class.getName())
+            .files(List.of("test1"))
+            .namespace("{{ inputs.namespace }}")
+            .build();
+
+        RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, downloadFiles, ImmutableMap.of("namespace", namespace));
+
+        DownloadFiles.Output output = downloadFiles.run(runContext);
+
+        assertThat(output.getFiles().size(), is(1));
+        assertThat(output.getFiles().get("a/b/test1.txt"), notNullValue());
+
+    }
+
+    private void put(String namespace, String path, String content) throws IOException {
+        storageInterface.put(
+            null,
+            URI.create(StorageContext.namespaceFilePrefix(namespace) + path),
+            new ByteArrayInputStream(content.getBytes())
+        );
+    }
+}
diff --git a/core/src/test/java/io/kestra/plugin/core/namespace/UploadFilesTest.java b/core/src/test/java/io/kestra/plugin/core/namespace/UploadFilesTest.java
new file mode 100644
index 000000000..08d322a98
--- /dev/null
+++ b/core/src/test/java/io/kestra/plugin/core/namespace/UploadFilesTest.java
@@ -0,0 +1,155 @@
+package io.kestra.plugin.core.namespace;
+
+import com.devskiller.friendly_id.FriendlyId;
+import com.google.common.collect.ImmutableMap;
+import io.kestra.core.runners.NamespaceFilesService;
+import io.kestra.core.runners.RunContext;
+import io.kestra.core.runners.RunContextFactory;
+import io.kestra.core.storages.StorageInterface;
+import io.kestra.core.utils.IdUtils;
+import io.kestra.core.utils.TestsUtils;
+import io.micronaut.test.extensions.junit5.annotation.MicronautTest;
+import jakarta.inject.Inject;
+import org.apache.commons.io.IOUtils;
+import org.junit.jupiter.api.Test;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.is;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+
+@MicronautTest
+public class UploadFilesTest {
+    @Inject
+    StorageInterface storageInterface;
+
+    @Inject
+    NamespaceFilesService namespaceFilesService;
+
+    @Inject
+    RunContextFactory runContextFactory;
+
+    @Test
+    void uploadConflictError() throws Exception {
+        String namespace = "io.kestra." + IdUtils.create();
+        File file = new File(Objects.requireNonNull(UploadFilesTest.class.getClassLoader().getResource("application-test.yml")).toURI());
+
+        URI fileStorage = storageInterface.put(
+            null,
+            new URI("/" + FriendlyId.createFriendlyId()),
+            new FileInputStream(file)
+        );
+        UploadFiles uploadFile = UploadFiles.builder()
+            .id(UploadFiles.class.getSimpleName())
+            .type(UploadFiles.class.getName())
+            .files(Map.of("/path/file.txt", fileStorage.toString()))
+            .namespace(namespace)
+            .conflict(UploadFiles.ConflictAction.ERROR)
+            .destination("/folder")
+            .build();
+
+        RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, uploadFile, ImmutableMap.of());
+        uploadFile.run(runContext);
+
+        assertThat(namespaceFilesService.recursiveList(null, namespace, URI.create("")).size(), is(1));
+
+        assertThrows(RuntimeException.class, () -> {
+            uploadFile.run(runContext);
+        });
+    }
+
+    @Test
+    void uploadConflictOverwrite() throws Exception {
+        String namespace = "io.kestra." + IdUtils.create();
+
+        URI fileStorage = addToStorage("application-test.yml");
+
+        UploadFiles uploadFile = UploadFiles.builder()
+            .id(UploadFiles.class.getSimpleName())
+            .type(UploadFiles.class.getName())
+            .files(Map.of("/path/file.txt", fileStorage.toString()))
+            .namespace("{{ inputs.namespace }}")
+            .destination("/folder")
+            .build();
+
+        RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, uploadFile,  ImmutableMap.of("namespace", namespace));
+        uploadFile.run(runContext);
+
+        List<URI> namespaceFiles = namespaceFilesService.recursiveList(null, namespace, URI.create(""));
+        assertThat(namespaceFiles.size(), is(1));
+
+        String previousFile = IOUtils.toString(namespaceFilesService.content(null, namespace, namespaceFiles.getFirst()), StandardCharsets.UTF_8);
+
+        fileStorage = addToStorage("logback.xml");
+        uploadFile = uploadFile.toBuilder()
+                .files(Map.of("/path/file.txt", fileStorage.toString()))
+                .build();
+
+        uploadFile.run(runContext);
+
+        namespaceFiles = namespaceFilesService.recursiveList(null, namespace, URI.create(""));
+        assertThat(namespaceFiles.size(), is(1));
+
+        String newFile = IOUtils.toString(namespaceFilesService.content(null, namespace, namespaceFiles.getFirst()), StandardCharsets.UTF_8);
+
+        assertThat(previousFile.equals(newFile), is(false));
+    }
+
+    @Test
+    void uploadConflictSkip() throws Exception {
+        String namespace = "io.kestra." + IdUtils.create();
+
+        URI fileStorage = addToStorage("application-test.yml");
+
+        UploadFiles uploadFile = UploadFiles.builder()
+            .id(UploadFiles.class.getSimpleName())
+            .type(UploadFiles.class.getName())
+            .files(Map.of("/path/file.txt", fileStorage.toString()))
+            .namespace(namespace)
+            .conflict(UploadFiles.ConflictAction.SKIP)
+            .destination("/folder")
+            .build();
+
+        RunContext runContext = TestsUtils.mockRunContext(this.runContextFactory, uploadFile, ImmutableMap.of());
+        uploadFile.run(runContext);
+
+        List<URI> namespaceFiles = namespaceFilesService.recursiveList(null, namespace, URI.create(""));
+        assertThat(namespaceFiles.size(), is(1));
+
+        String previousFile = IOUtils.toString(namespaceFilesService.content(null, namespace, namespaceFiles.getFirst()), StandardCharsets.UTF_8);
+
+        fileStorage = addToStorage("logback.xml");
+        uploadFile = uploadFile.toBuilder()
+            .files(Map.of("/path/file.txt", fileStorage.toString()))
+            .build();
+
+        uploadFile.run(runContext);
+
+        namespaceFiles = namespaceFilesService.recursiveList(null, namespace, URI.create(""));
+        assertThat(namespaceFiles.size(), is(1));
+
+        String newFile = IOUtils.toString(namespaceFilesService.content(null, namespace, namespaceFiles.getFirst()), StandardCharsets.UTF_8);
+
+        assertThat(previousFile.equals(newFile), is(true));
+    }
+
+    private URI addToStorage(String fileToLoad) throws IOException, URISyntaxException {
+        File file = new File(Objects.requireNonNull(UploadFilesTest.class.getClassLoader().getResource(fileToLoad)).toURI());
+
+        return storageInterface.put(
+            null,
+            new URI("/" + FriendlyId.createFriendlyId()),
+            new FileInputStream(file)
+        );
+    }
+}