Repository: kestra-io/kestra (java)

Issue:
Support creating, deleting and updating individual flows from the CLI
### Feature description

Use-case: a flow needs to be created, deleted or updated from the CLI

Expected behavior:
- When creating a new flow, the file should be pushed to the server. If a flow with the same id already exists, it should fail.
- When deleting a flow, the id parameter should be used to remove it from the server. If the id is unknown, it should fail.
- When updating a flow, the file should be pushed to the server and the flow with the same id, replaced. Ideally, the revision number should be increased. If the id is unknown, it should fail.

Thanks :)

Pull request description:
feat(core): cli command for update/create/delete single flow
close #747

Patch:
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java
new file mode 100644
index 000000000..2e85874ea
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowCreateCommand.java
@@ -0,0 +1,59 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+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 lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@CommandLine.Command(
+    name = "create",
+    description = "create a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowCreateCommand extends AbstractApiCommand {
+    @CommandLine.Parameters(index = "0", description = "the file containing the flow")
+    public Path flowFile;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        checkFile();
+
+        String body = Files.readString(flowFile);
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .POST(apiUri("/flows"), body).contentType(MediaType.APPLICATION_YAML);
+
+            client.toBlocking().retrieve(
+                this.requestOptions(request),
+                String.class
+            );
+
+            stdOut("Flow successfully created !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+
+        return 0;
+    }
+
+    protected void checkFile() {
+        if (!Files.isRegularFile(flowFile)) {
+            throw new IllegalArgumentException("The file '" + flowFile.toFile().getAbsolutePath() + "' is not a file");
+        }
+    }
+}
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java
new file mode 100644
index 000000000..b484da2ef
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowDeleteCommand.java
@@ -0,0 +1,47 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+import io.micronaut.http.HttpRequest;
+import io.micronaut.http.MutableHttpRequest;
+import io.micronaut.http.client.exceptions.HttpClientResponseException;
+import io.micronaut.http.client.netty.DefaultHttpClient;
+import lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+@CommandLine.Command(
+    name = "create",
+    description = "create a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowDeleteCommand extends AbstractApiCommand {
+
+    @CommandLine.Parameters(index = "0", description = "the namespace of the flow")
+    public String namespace;
+
+    @CommandLine.Parameters(index = "1", description = "the id of the flow")
+    public String id;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .DELETE(apiUri("/flows/" + namespace + "/" + id ));
+
+            client.toBlocking().exchange(
+                this.requestOptions(request)
+            );
+
+            stdOut("Flow successfully deleted !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+        return 0;
+    }
+}
diff --git a/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java
new file mode 100644
index 000000000..7cad07423
--- /dev/null
+++ b/cli/src/main/java/io/kestra/cli/commands/flows/FlowUpdateCommand.java
@@ -0,0 +1,64 @@
+package io.kestra.cli.commands.flows;
+
+import io.kestra.cli.AbstractApiCommand;
+import io.kestra.cli.AbstractValidateCommand;
+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 lombok.extern.slf4j.Slf4j;
+import picocli.CommandLine;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+@CommandLine.Command(
+    name = "update",
+    description = "update a single flow",
+    mixinStandardHelpOptions = true
+)
+@Slf4j
+public class FlowUpdateCommand extends AbstractApiCommand {
+    @CommandLine.Parameters(index = "0", description = "the file containing the flow")
+    public Path flowFile;
+
+    @CommandLine.Parameters(index = "1", description = "the namespace of the flow")
+    public String namespace;
+
+    @CommandLine.Parameters(index = "2", description = "the id of the flow")
+    public String id;
+
+    @SuppressWarnings("deprecation")
+    @Override
+    public Integer call() throws Exception {
+        super.call();
+
+        checkFile();
+
+        String body = Files.readString(flowFile);
+
+        try(DefaultHttpClient client = client()) {
+            MutableHttpRequest<String> request = HttpRequest
+                .PUT(apiUri("/flows/" + namespace + "/" + id ), body).contentType(MediaType.APPLICATION_YAML);
+
+            client.toBlocking().retrieve(
+                this.requestOptions(request),
+                String.class
+            );
+
+            stdOut("Flow successfully updated !");
+        } catch (HttpClientResponseException e){
+            AbstractValidateCommand.handleHttpException(e, "flow");
+            return 1;
+        }
+
+        return 0;
+    }
+
+    protected void checkFile() {
+        if (!Files.isRegularFile(flowFile)) {
+            throw new IllegalArgumentException("The file '" + flowFile.toFile().getAbsolutePath() + "' is not a file");
+        }
+    }
+}


Test patch:
diff --git a/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java b/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java
new file mode 100644
index 000000000..a6cdc3884
--- /dev/null
+++ b/cli/src/test/java/io/kestra/cli/commands/flows/SingleFlowCommandsTest.java
@@ -0,0 +1,71 @@
+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.StringContains.containsString;
+
+public class SingleFlowCommandsTest {
+
+
+    @Test
+    void all() {
+        URL flow = SingleFlowCommandsTest.class.getClassLoader().getResource("flows/quattro.yml");
+        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[] deleteArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                "io.kestra.outsider",
+                "quattro"
+            };
+            PicocliRunner.call(FlowDeleteCommand.class, ctx, deleteArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully deleted !"));
+            out.reset();
+
+            String[] createArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                flow.getPath(),
+            };
+            PicocliRunner.call(FlowCreateCommand.class, ctx, createArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully created !"));
+
+
+            out.reset();String[] updateArgs = {
+                "--server",
+                embeddedServer.getURL().toString(),
+                "--user",
+                "myuser:pass:word",
+                flow.getPath(),
+                "io.kestra.outsider",
+                "quattro"
+            };
+            PicocliRunner.call(FlowUpdateCommand.class, ctx, updateArgs);
+
+            assertThat(out.toString(), containsString("Flow successfully updated !"));
+            out.reset();
+        }
+    }
+
+}