Repository: kestra-io/kestra (java)

Issue:
[CLI] Allow to overwrite the namespace in the namespace flow update command
### Feature description

The objective is to be able to set different value in the CLI (especially for the CI) so you can set different namespace based on the targeted instance (dev, int, prod..)

Pull request:
feat(core): update CLI
Create new command to allows update multiple flows whatev…er the namespace and update the FlowNamespaceUpdateCommand to add an option to override all namespaces

close #5377 

Patch:
diff --git a/cli/src/main/java/io/kestra/cli/AbstractApiCommand.java b/cli/src/main/java/io/kestra/cli/AbstractApiCommand.java
index a9455feb1..d09aed3ef 100644
--- a/cli/src/main/java/io/kestra/cli/AbstractApiCommand.java
+++ b/cli/src/main/java/io/kestra/cli/AbstractApiCommand.java
@@ -14,6 +14,9 @@ import io.micronaut.http.netty.body.NettyJsonHandler;
 import io.micronaut.json.JsonMapper;
 import jakarta.inject.Inject;
 import jakarta.inject.Named;
+import lombok.Builder;
+import lombok.Value;
+import lombok.extern.jackson.Jacksonized;
 import picocli.CommandLine;
 
 import java.net.URISyntaxException;
@@ -79,4 +82,12 @@ public abstract class AbstractApiCommand extends AbstractCommand {
 
         return tenantId == null ? "/api/v1" + path : "/api/v1/" + tenantId + path;
     }
+
+    @Builder
+    @Value
+    @Jacksonized
+    public static class UpdateResult {
+        String id;
+        String namespace;
+    }
 }
diff --git a/cli/src/main/java/io/kestra/cli/commands/AbstractServiceNamespaceUpdateCommand.java b/cli/src/main/java/io/kestra/cli/commands/AbstractServiceNamespaceUpdateCommand.java
index 3d25551e6..b0a6bd42a 100644
--- a/cli/src/main/java/io/kestra/cli/commands/AbstractServiceNamespaceUpdateCommand.java
+++ b/cli/src/main/java/io/kestra/cli/commands/AbstractServiceNamespaceUpdateCommand.java
@@ -18,11 +18,4 @@ public abstract class AbstractServiceNamespaceUpdateCommand extends AbstractApiC
     @CommandLine.Option(names = {"--delete"}, negatable = true, description = "if missing should be deleted")
     public boolean delete = false;
 
-    @Builder
-    @Value
-    @Jacksonized
-    public static class UpdateResult {
-        String id;
-        String namespace;
-    }
 }
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdatesCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdatesCommand.java
new file mode 100644
index 000000000..c8de5e23c
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdatesCommand.java
@@ -0,0 +1,83 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+import io.kestra.core.serializers.YamlFlowParser;
+import io.micronaut.core.type.Argument;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MediaType;
+import io.micronaut.http.MutableHttpRequest;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.http.client.netty.DefaultHttpClient;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+@CommandLine.Command(
+    name = "updates",
+    description = "create or update flows from a folder, and optionally delete the ones not present",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowUpdatesCommand extends AbstractApiCommand {
+
+    @CommandLine.Parameters(index = "0", description = "the directory containing files")
+    public Path directory;
+
+    @CommandLine.Option(names = {"--delete"}, negatable = true, description = "if missing should be deleted")
+    public boolean delete = false;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        try (var files = Files.walk(directory)) {
+            List<String> flows = files
+                .filter(Files::isRegularFile)
+                .filter(YamlFlowParser::isValidExtension)
+                .map(path -> {
+                    try {
+                        return IncludeHelperExpander.expand(Files.readString(path, Charset.defaultCharset()), path.getParent());
+                    } catch (IOException e) {
+                        throw new RuntimeException(e);
+                    }
+                })
+                .toList();
+
+            String body = "";
+            if (flows.isEmpty()) {
+                stdOut("No flow found on '{}'", directory.toFile().getAbsolutePath());
+            } else {
+                body = String.join("\n---\n", flows);
+            }
+            try(DefaultHttpClient client = client()) {
+                MutableHttpRequest<String> request = HttpRequest
+                    .POST(apiUri("/flows/bulk") + "?delete=" + delete, body).contentType(MediaType.APPLICATION_YAML);
+
+                List<UpdateResult> updated = client.toBlocking().retrieve(
+                    this.requestOptions(request),
+                    Argument.listOf(UpdateResult.class)
+                );
+
+                stdOut(updated.size() + " flow(s) successfully updated !");
+                updated.forEach(flow -> stdOut("- " + flow.getNamespace() + "."  + flow.getId()));
+            } catch (HttpClientResponseException e){
+                AbstractValidateCommand.handleHttpException(e, "flow");
+                return 1;
+            }
+        } catch (ConstraintViolationException e) {
+            AbstractValidateCommand.handleException(e, "flow");
+
+            return 1;
+        }
+
+        return 0;
+    }
+}
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommand.java
index f19726915..0ff05ef36 100644
--- a/cli/src/main/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommand.java
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommand.java
@@ -2,7 +2,6 @@ package io.kestra.cli.commands.flows.namespaces;
 
 import io.kestra.cli.AbstractValidateCommand;
 import io.kestra.cli.commands.AbstractServiceNamespaceUpdateCommand;
-import io.kestra.cli.commands.flows.FlowValidateCommand;
 import io.kestra.cli.commands.flows.IncludeHelperExpander;
 import io.kestra.core.serializers.YamlFlowParser;
 import io.micronaut.core.type.Argument;
@@ -12,10 +11,10 @@ import io.micronaut.http.MutableHttpRequest;
 import io.micronaut.http.client.exceptions.HttpClientResponseException;
 import io.micronaut.http.client.netty.DefaultHttpClient;
 import jakarta.inject.Inject;
+import jakarta.validation.ConstraintViolationException;
 import lombok.extern.slf4j.Slf4j;
 import picocli.CommandLine;
 
-import jakarta.validation.ConstraintViolationException;
 import java.io.IOException;
 import java.nio.charset.Charset;
 import java.nio.file.Files;
@@ -31,6 +30,9 @@ public class FlowNamespaceUpdateCommand extends AbstractServiceNamespaceUpdateCo
     @Inject
     public YamlFlowParser yamlFlowParser;
 
+    @CommandLine.Option(names = {"--override-namespaces"}, negatable = true, description = "replace namespace of all flows by the one provided")
+    public boolean override = false;
+
     @SuppressWarnings("deprecation")
     @Override
     public Integer call() throws Exception {
@@ -55,6 +57,9 @@ public class FlowNamespaceUpdateCommand extends AbstractServiceNamespaceUpdateCo
             } else {
                 body = String.join("\n---\n", flows);
             }
+            if (override) {
+                body = body.replaceAll("(?m)^namespace:.+", "namespace: " + namespace);
+            }
             try(DefaultHttpClient client = client()) {
                 MutableHttpRequest<String> request = HttpRequest
                     .POST(apiUri("/flows/") + namespace + "?delete=" + delete, body).contentType(MediaType.APPLICATION_YAML);
diff --git a/cli/src/test/resources/flows/first.yaml b/cli/src/test/resources/flows/same/first.yaml
similarity index 100%
rename from cli/src/test/resources/flows/first.yaml
rename to cli/src/test/resources/flows/same/first.yaml
diff --git a/cli/src/test/resources/flows/flowsSubFolder/third.yaml b/cli/src/test/resources/flows/same/flowsSubFolder/third.yaml
similarity index 100%
rename from cli/src/test/resources/flows/flowsSubFolder/third.yaml
rename to cli/src/test/resources/flows/same/flowsSubFolder/third.yaml
diff --git a/cli/src/test/resources/flows/second.yaml b/cli/src/test/resources/flows/same/second.yaml
similarity index 100%
rename from cli/src/test/resources/flows/second.yaml
rename to cli/src/test/resources/flows/same/second.yaml
diff --git a/cli/src/test/resources/helper/flow.yaml b/cli/src/test/resources/helper/include.yaml
similarity index 100%
rename from cli/src/test/resources/helper/flow.yaml
rename to cli/src/test/resources/helper/include.yaml
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java
index c868604bc..43926c530 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/FlowController.java
@@ -1,5 +1,8 @@
 package io.kestra.webserver.controllers.api;
 
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
+import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.exceptions.InternalException;
 import io.kestra.core.models.SearchResult;
@@ -19,8 +22,8 @@ import io.kestra.core.repositories.FlowRepositoryInterface;
 import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
 import io.kestra.core.serializers.JacksonMapper;
 import io.kestra.core.serializers.YamlFlowParser;
-import io.kestra.core.services.GraphService;
 import io.kestra.core.services.FlowService;
+import io.kestra.core.services.GraphService;
 import io.kestra.core.services.PluginDefaultService;
 import io.kestra.core.tenant.TenantService;
 import io.kestra.core.topologies.FlowTopologyService;
@@ -45,11 +48,11 @@ import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.media.Schema;
 import io.swagger.v3.oas.annotations.responses.ApiResponse;
 import jakarta.inject.Inject;
+import jakarta.validation.ConstraintViolationException;
+import jakarta.validation.Valid;
 import jakarta.validation.constraints.Min;
 import lombok.extern.slf4j.Slf4j;
 
-import jakarta.validation.ConstraintViolationException;
-import jakarta.validation.Valid;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.util.*;
@@ -60,10 +63,6 @@ import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
 import java.util.zip.ZipOutputStream;
 
-import com.fasterxml.jackson.core.JsonProcessingException;
-import com.fasterxml.jackson.databind.exc.InvalidTypeIdException;
-import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException;
-
 import static io.kestra.core.utils.Rethrow.throwFunction;
 
 @Validated
@@ -113,13 +112,13 @@ public class FlowController {
             .orElse(null);
 
         String flowUid = revision.isEmpty() ? Flow.uidWithoutRevision(tenantService.resolveTenant(), namespace, id) : Flow.uid(tenantService.resolveTenant(), namespace, id, revision);
-        if(flow == null) {
+        if (flow == null) {
             throw new NoSuchElementException(
                 "Unable to find flow " + flowUid
             );
         }
 
-        if(flow instanceof FlowWithException fwe) {
+        if (flow instanceof FlowWithException fwe) {
             throw new IllegalStateException(
                 "Unable to generate graph for flow " + flowUid +
                     " because of exception " + fwe.getException()
@@ -288,7 +287,7 @@ public class FlowController {
     ) throws ConstraintViolationException {
         List<String> sources = flows != null ? List.of(flows.split("---")) : new ArrayList<>();
 
-        return this.updateCompleteNamespace(
+        return this.bulkUpdateOrCreate(
             namespace,
             sources
                 .stream()
@@ -319,7 +318,7 @@ public class FlowController {
         log.warn(WARNING_JSON_FLOW_ENDPOINT);
 
         return this
-            .updateCompleteNamespace(
+            .bulkUpdateOrCreate(
                 namespace,
                 flows
                     .stream()
@@ -332,22 +331,25 @@ public class FlowController {
             .toList();
     }
 
-    protected List<FlowWithSource> updateCompleteNamespace(String namespace, List<FlowWithSource> flows, Boolean delete) {
-        // control namespace to update
-        Set<ManualConstraintViolation<Flow>> invalids = flows
-            .stream()
-            .filter(flow -> !flow.getNamespace().equals(namespace))
-            .map(flow -> ManualConstraintViolation.of(
-                "Flow namespace is invalid",
-                flow,
-                Flow.class,
-                "flow.namespace",
-                flow.getNamespace()
-            ))
-            .collect(Collectors.toSet());
-
-        if (!invalids.isEmpty()) {
-            throw new ConstraintViolationException(invalids);
+    protected List<FlowWithSource> bulkUpdateOrCreate(@Nullable String namespace, List<FlowWithSource> flows, Boolean delete) {
+
+        if (namespace != null) {
+            // control namespace to update
+            Set<ManualConstraintViolation<Flow>> invalids = flows
+                .stream()
+                .filter(flow -> !flow.getNamespace().equals(namespace))
+                .map(flow -> ManualConstraintViolation.of(
+                    "Flow namespace is invalid",
+                    flow,
+                    Flow.class,
+                    "flow.namespace",
+                    flow.getNamespace()
+                ))
+                .collect(Collectors.toSet());
+
+            if (!invalids.isEmpty()) {
+                throw new ConstraintViolationException(invalids);
+            }
         }
 
         // multiple same flows
@@ -376,9 +378,14 @@ public class FlowController {
         // delete all not in updated ids
         List<FlowWithSource> deleted = new ArrayList<>();
         if (delete) {
-            deleted = flowRepository
-                .findByNamespaceWithSource(tenantService.resolveTenant(), namespace)
-                .stream()
+            if (namespace != null) {
+                deleted = flowRepository
+                    .findByNamespaceWithSource(tenantService.resolveTenant(), namespace);
+            } else {
+                deleted = flowRepository
+                    .findAllWithSource(tenantService.resolveTenant());
+            }
+            deleted = deleted.stream()
                 .filter(flow -> !ids.contains(flow.getId()))
                 .peek(flow -> flowRepository.delete(flow))
                 .toList();
@@ -387,7 +394,7 @@ public class FlowController {
         // update or create flows
         List<FlowWithSource> updatedOrCreated = flows.stream()
             .map(flowWithSource -> {
-                Optional<Flow> existingFlow = flowRepository.findById(tenantService.resolveTenant(), namespace, flowWithSource.getId());
+                Optional<Flow> existingFlow = flowRepository.findById(tenantService.resolveTenant(), flowWithSource.getNamespace(), flowWithSource.getId());
                 if (existingFlow.isPresent()) {
                     return flowRepository.update(flowWithSource, existingFlow.get(), flowWithSource.getSource(), pluginDefaultService.injectDefaults(flowWithSource));
                 } else {
@@ -443,6 +450,30 @@ public class FlowController {
         return flowRepository.update(current, previous, source, pluginDefaultService.injectDefaults(current.withSource(source)));
     }
 
+    @ExecuteOn(TaskExecutors.IO)
+    @Post(uri = "bulk", consumes = MediaType.APPLICATION_YAML)
+    @Operation(
+        tags = {"Flows"},
+        summary = "Update from multiples yaml sources",
+        description = "All flow will be created / updated for this namespace.\n" +
+            "Flow that already created but not in `flows` will be deleted if the query delete is `true`"
+    )
+    public List<FlowWithSource> bulkUpdate(
+        @Parameter(description = "A list of flows") @Body @Nullable String flows,
+        @Parameter(description = "If missing flow should be deleted") @QueryValue(defaultValue = "true") Boolean delete
+    ) throws ConstraintViolationException {
+        List<String> sources = flows != null ? List.of(flows.split("---")) : new ArrayList<>();
+
+        return this.bulkUpdateOrCreate(
+            null,
+            sources
+                .stream()
+                .map(flow -> FlowWithSource.of(yamlFlowParser.parse(flow, Flow.class), flow.trim()))
+                .toList(),
+            delete
+        );
+    }
+
     /**
      * @deprecated should not be used anymore
      */
@@ -887,8 +918,7 @@ public class FlowController {
         } catch (JsonProcessingException e) {
             if (e.getCause() instanceof ConstraintViolationException constraintViolationException) {
                 throw constraintViolationException;
-            }
-            else if (e instanceof InvalidTypeIdException invalidTypeIdException) {
+            } else if (e instanceof InvalidTypeIdException invalidTypeIdException) {
                 // This error is thrown when a non-existing task is used
                 throw new ConstraintViolationException(
                     "Invalid type: " + invalidTypeIdException.getTypeId(),


Test patch:
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowCreateOrUpdateCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowCreateOrUpdateCommandTest.java
new file mode 100644
index 000000000..0f29db8b4
--- /dev/null
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowCreateOrUpdateCommandTest.java
@@ -0,0 +1,137 @@
+package io.kestra.cli.commands.flows;
+
+import io.micronaut.configuration.picocli.PicocliRunner;
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.env.Environment;
+import io.micronaut.runtime.server.EmbeddedServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.net.URL;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.StringContains.containsString;
+
+class FlowCreateOrUpdateCommandTest {
+    @Test
+    void runWithDelete()  {
+        URL directory = FlowCreateOrUpdateCommandTest.class.getClassLoader().getResource("flows");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+        URL subDirectory = FlowCreateOrUpdateCommandTest.class.getClassLoader().getResource("flows/same");
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--delete",
+                directory.getPath(),
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("4 flow(s)"));
+            out.reset();
+
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--delete",
+                subDirectory.getPath(),
+
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            // 2 delete + 1 update
+            assertThat(out.toString(), containsString("4 flow(s)"));
+        }
+    }
+
+    @Test
+    void runNoDelete()  {
+        URL directory = FlowCreateOrUpdateCommandTest.class.getClassLoader().getResource("flows");
+        URL subDirectory = FlowCreateOrUpdateCommandTest.class.getClassLoader().getResource("flows/same/flowsSubFolder");
+
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                directory.getPath(),
+
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("4 flow(s)"));
+            out.reset();
+
+            // no "delete" arg should behave as no-delete
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                subDirectory.getPath()
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("1 flow(s)"));
+            out.reset();
+
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--no-delete",
+                subDirectory.getPath()
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("1 flow(s)"));
+        }
+    }
+
+    @Test
+    void helper()  {
+        URL directory = FlowCreateOrUpdateCommandTest.class.getClassLoader().getResource("helper");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                directory.getPath(),
+
+            };
+            Integer call = PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(call, is(0));
+            assertThat(out.toString(), containsString("1 flow(s)"));
+        }
+    }
+}
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowDotCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowDotCommandTest.java
index 69a70f5a2..3e22e5a42 100644
--- a/cli/src/test/java/io/kestra/cli/commands/flows/FlowDotCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowDotCommandTest.java
@@ -16,7 +16,7 @@ import static org.hamcrest.core.StringContains.containsString;
 class FlowDotCommandTest {
     @Test
     void run()  {
-        URL directory = TemplateValidateCommandTest.class.getClassLoader().getResource("flows/first.yaml");
+        URL directory = TemplateValidateCommandTest.class.getClassLoader().getResource("flows/same/first.yaml");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
 
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowExpandCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowExpandCommandTest.java
index eb985c126..2fabb34a7 100644
--- a/cli/src/test/java/io/kestra/cli/commands/flows/FlowExpandCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowExpandCommandTest.java
@@ -19,7 +19,7 @@ class FlowExpandCommandTest {
 
         try (ApplicationContext ctx = ApplicationContext.builder().deduceEnvironment(false).start()) {
             String[] args = {
-                "src/test/resources/helper/flow.yaml"
+                "src/test/resources/helper/include.yaml"
             };
             Integer call = PicocliRunner.call(FlowExpandCommand.class, ctx, args);
 
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowExportCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowExportCommandTest.java
index 011debd93..4702d1942 100644
--- a/cli/src/test/java/io/kestra/cli/commands/flows/FlowExportCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowExportCommandTest.java
@@ -22,7 +22,7 @@ import static org.hamcrest.core.StringContains.containsString;
 class FlowExportCommandTest {
     @Test
     void run() throws IOException {
-        URL directory = FlowExportCommandTest.class.getClassLoader().getResource("flows");
+        URL directory = FlowExportCommandTest.class.getClassLoader().getResource("flows/same");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
 
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowUpdatesCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowUpdatesCommandTest.java
new file mode 100644
index 000000000..9970328bb
--- /dev/null
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowUpdatesCommandTest.java
@@ -0,0 +1,137 @@
+package io.kestra.cli.commands.flows;
+
+import io.micronaut.configuration.picocli.PicocliRunner;
+import io.micronaut.context.ApplicationContext;
+import io.micronaut.context.env.Environment;
+import io.micronaut.runtime.server.EmbeddedServer;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.PrintStream;
+import java.net.URL;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.StringContains.containsString;
+
+class FlowUpdatesCommandTest {
+    @Test
+    void runWithDelete()  {
+        URL directory = FlowUpdatesCommandTest.class.getClassLoader().getResource("flows");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+        URL subDirectory = FlowUpdatesCommandTest.class.getClassLoader().getResource("flows/same");
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--delete",
+                directory.getPath(),
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("successfully updated !"));
+            out.reset();
+
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--delete",
+                subDirectory.getPath(),
+
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            // 2 delete + 1 update
+            assertThat(out.toString(), containsString("successfully updated !"));
+        }
+    }
+
+    @Test
+    void runNoDelete()  {
+        URL directory = FlowUpdatesCommandTest.class.getClassLoader().getResource("flows");
+        URL subDirectory = FlowUpdatesCommandTest.class.getClassLoader().getResource("flows/same/flowsSubFolder");
+
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                directory.getPath(),
+
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("4 flow(s)"));
+            out.reset();
+
+            // no "delete" arg should behave as no-delete
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                subDirectory.getPath()
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("1 flow(s)"));
+            out.reset();
+
+            args = new String[]{
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "--no-delete",
+                subDirectory.getPath()
+            };
+            PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("1 flow(s)"));
+        }
+    }
+
+    @Test
+    void helper()  {
+        URL directory = FlowUpdatesCommandTest.class.getClassLoader().getResource("helper");
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                directory.getPath(),
+
+            };
+            Integer call = PicocliRunner.call(FlowUpdatesCommand.class, ctx, args);
+
+            assertThat(call, is(0));
+            assertThat(out.toString(), containsString("1 flow(s)"));
+        }
+    }
+}
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/FlowValidateCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/FlowValidateCommandTest.java
index eb3dc06fb..a552e20ae 100644
--- a/cli/src/test/java/io/kestra/cli/commands/flows/FlowValidateCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/FlowValidateCommandTest.java
@@ -20,7 +20,7 @@ class FlowValidateCommandTest {
         try (ApplicationContext ctx = ApplicationContext.builder().deduceEnvironment(false).start()) {
             String[] args = {
                 "--local",
-                "src/test/resources/helper/flow.yaml"
+                "src/test/resources/helper/include.yaml"
             };
             Integer call = PicocliRunner.call(FlowValidateCommand.class, ctx, args);
 
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommandTest.java
index 3437a90dc..1dd78193c 100644
--- a/cli/src/test/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/namespaces/FlowNamespaceUpdateCommandTest.java
@@ -11,16 +11,17 @@ import java.io.PrintStream;
 import java.net.URL;
 
 import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.not;
 import static org.hamcrest.core.StringContains.containsString;
 import static org.hamcrest.core.Is.is;
 
 class FlowNamespaceUpdateCommandTest {
     @Test
-    void runWithDelete()  {
-        URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows");
+    void runWithDelete() {
+        URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/same");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
-        URL subDirectory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/flowsSubFolder");
+        URL subDirectory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/same/flowsSubFolder");
 
         try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
 
@@ -38,7 +39,7 @@ class FlowNamespaceUpdateCommandTest {
             };
             PicocliRunner.call(FlowNamespaceUpdateCommand.class, ctx, args);
 
-            assertThat(out.toString(), containsString("3 flow(s)"));
+            assertThat(out.toString(), containsString("namespace 'io.kestra.cli' successfully updated"));
             out.reset();
 
             args = new String[]{
@@ -54,12 +55,12 @@ class FlowNamespaceUpdateCommandTest {
             PicocliRunner.call(FlowNamespaceUpdateCommand.class, ctx, args);
 
             // 2 delete + 1 update
-            assertThat(out.toString(), containsString("3 flow(s)"));
+            assertThat(out.toString(), containsString("namespace 'io.kestra.cli' successfully updated"));
         }
     }
 
     @Test
-    void invalid()  {
+    void invalid() {
         URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("invalids");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setErr(new PrintStream(out));
@@ -87,9 +88,9 @@ class FlowNamespaceUpdateCommandTest {
     }
 
     @Test
-    void runNoDelete()  {
-        URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows");
-        URL subDirectory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/flowsSubFolder");
+    void runNoDelete() {
+        URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/same");
+        URL subDirectory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/same/flowsSubFolder");
 
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
@@ -143,7 +144,7 @@ class FlowNamespaceUpdateCommandTest {
     }
 
     @Test
-    void helper()  {
+    void helper() {
         URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("helper");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
@@ -168,4 +169,35 @@ class FlowNamespaceUpdateCommandTest {
             assertThat(out.toString(), containsString("1 flow(s)"));
         }
     }
+
+    @Test
+    void runOverride() {
+        URL directory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows");
+        URL subDirectory = FlowNamespaceUpdateCommandTest.class.getClassLoader().getResource("flows/same/flowsSubFolder");
+
+        ByteArrayOutputStream out = new ByteArrayOutputStream();
+        System.setOut(new PrintStream(out));
+
+        try (ApplicationContext ctx = ApplicationContext.run(Environment.CLI, Environment.TEST)) {
+
+            EmbeddedServer embeddedServer = ctx.getBean(EmbeddedServer.class);
+            embeddedServer.start();
+
+            String[] args = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "io.kestra.override",
+                "--override-namespaces",
+                directory.getPath(),
+
+            };
+            PicocliRunner.call(FlowNamespaceUpdateCommand.class, ctx, args);
+
+            assertThat(out.toString(), containsString("io.kestra.override"));
+            assertThat(out.toString(), not(containsString("io.kestra.cli")));
+
+        }
+    }
 }
diff --git a/cli/src/test/java/io/kestra/cli/commands/sys/ReindexCommandTest.java b/cli/src/test/java/io/kestra/cli/commands/sys/ReindexCommandTest.java
index 50ec43bd4..cbe75fd3e 100644
--- a/cli/src/test/java/io/kestra/cli/commands/sys/ReindexCommandTest.java
+++ b/cli/src/test/java/io/kestra/cli/commands/sys/ReindexCommandTest.java
@@ -18,7 +18,7 @@ import static org.hamcrest.core.StringContains.containsString;
 class ReindexCommandTest {
     @Test
     void reindexFlow() {
-        URL directory = ReindexCommandTest.class.getClassLoader().getResource("flows");
+        URL directory = ReindexCommandTest.class.getClassLoader().getResource("flows/same");
         ByteArrayOutputStream out = new ByteArrayOutputStream();
         System.setOut(new PrintStream(out));
 
diff --git a/cli/src/test/resources/flows/quattro.yml b/cli/src/test/resources/flows/quattro.yml
new file mode 100644
index 000000000..dbead21e7
--- /dev/null
+++ b/cli/src/test/resources/flows/quattro.yml
@@ -0,0 +1,7 @@
+id: quattro
+namespace: io.kestra.outsider
+
+tasks:
+  - id: date
+    type: io.kestra.plugin.core.debug.Return
+    format: "{{taskrun.startDate}}"