Repository: kestra-io/kestra (java)

Issue:
Provides a better error message when Kestra cannot be started due to misconfiguration
### Feature description

When Kestra cannot be started due to misconfiguration, en error like this one is displayed on the log:

```
D:\code\kestra> .\kestra-0.17.7.bat server standalone
Exception in thread "main" io.micronaut.context.exceptions.BeanInstantiationException: Bean definition [io.kestra.webserver.services.BasicAuthService] could not be loaded: Failed to inject value for field [settingRepository] of class: io.kestra.webserver.services.BasicAuthService

Message: No bean of type [io.kestra.core.repositories.SettingRepositoryInterface] exists.
* [H2SettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [h2] not present
* [MysqlSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [mysql] not present
* [PostgresSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [postgres] not present
Path Taken: new BasicAuthService() --> BasicAuthService.settingRepository
        at io.micronaut.context.DefaultBeanContext.initializeContext(DefaultBeanContext.java:1982)
        at io.micronaut.context.DefaultApplicationContext.initializeContext(DefaultApplicationContext.java:294)
        at io.micronaut.context.DefaultBeanContext.readAllBeanDefinitionClasses(DefaultBeanContext.java:3348)
        at io.micronaut.context.DefaultBeanContext.finalizeConfiguration(DefaultBeanContext.java:3692)
        at io.micronaut.context.DefaultBeanContext.start(DefaultBeanContext.java:342)
        at io.micronaut.context.DefaultApplicationContext.start(DefaultApplicationContext.java:202)
        at io.micronaut.configuration.picocli.MicronautFactory.<init>(MicronautFactory.java:59)
        at io.kestra.cli.App.execute(App.java:67)
        at io.kestra.cli.App.main(App.java:50)
Caused by: io.micronaut.context.exceptions.DependencyInjectionException: Failed to inject value for field [settingRepository] of class: io.kestra.webserver.services.BasicAuthService

Message: No bean of type [io.kestra.core.repositories.SettingRepositoryInterface] exists.
* [H2SettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [h2] not present
* [MysqlSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [mysql] not present
* [PostgresSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [postgres] not present
Path Taken: new BasicAuthService() --> BasicAuthService.settingRepository
        at io.micronaut.context.AbstractInitializableBeanDefinition.resolveBean(AbstractInitializableBeanDefinition.java:2177)
        at io.micronaut.context.AbstractInitializableBeanDefinition.getBeanForField(AbstractInitializableBeanDefinition.java:1668)
        at io.kestra.webserver.services.$BasicAuthService$Definition.inject(Unknown Source)
        at io.kestra.webserver.services.$BasicAuthService$Definition.instantiate(Unknown Source)
        at io.micronaut.context.DefaultBeanContext.resolveByBeanFactory(DefaultBeanContext.java:2311)
        at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2281)
        at io.micronaut.context.DefaultBeanContext.doCreateBean(DefaultBeanContext.java:2293)
        at io.micronaut.context.DefaultBeanContext.createRegistration(DefaultBeanContext.java:3095)
        at io.micronaut.context.SingletonScope.getOrCreate(SingletonScope.java:80)
        at io.micronaut.context.DefaultBeanContext.findOrCreateSingletonBeanRegistration(DefaultBeanContext.java:2997)
        at io.micronaut.context.DefaultBeanContext.initializeEagerBean(DefaultBeanContext.java:2678)
        at io.micronaut.context.DefaultBeanContext.initializeContext(DefaultBeanContext.java:1976)
        ... 8 more
Caused by: io.micronaut.context.exceptions.NoSuchBeanException: No bean of type [io.kestra.core.repositories.SettingRepositoryInterface] exists.
* [H2SettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [h2] not present
* [MysqlSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [mysql] not present
* [PostgresSettingRepository] a candidate of [SettingRepositoryInterface] is disabled because:
 - Required property [kestra.repository.type] with value [postgres] not present
        at io.micronaut.context.DefaultBeanContext.newNoSuchBeanException(DefaultBeanContext.java:2768)
        at io.micronaut.context.DefaultApplicationContext.newNoSuchBeanException(DefaultApplicationContext.java:309)
        at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2737)
        at io.micronaut.context.DefaultBeanContext.getBean(DefaultBeanContext.java:1731)
        at io.micronaut.context.AbstractBeanResolutionContext.getBean(AbstractBeanResolutionContext.java:89)
        at io.micronaut.context.AbstractInitializableBeanDefinition.resolveBean(AbstractInitializableBeanDefinition.java:2161)
        ... 19 more
```

A user not knowing how Kestra work has no way to understand what's going on, this is usually due to missing configuration so we should detect this and points to the correct configuration guide.

We should check preconditions on all Kestra server commands and display a nice error message for the common cause of misconfiguration that is missing queue, repository or storage configuration.

Pull request description:
feat(cli): provide descriptive server start error
### What changes are being made and why?

* The validation is based on just 3 basic Kestra properties.
* Provided a description and link to the docs in the error.
* The `server local` nor any other non-`server` commands were affected.
* Added a crude test.

closes #4155

Patch:
diff --git a/cli/src/main/java/io/kestra/cli/App.java b/cli/src/main/java/io/kestra/cli/App.java
index cc6b4ebd1..1ec2797f3 100644
--- a/cli/src/main/java/io/kestra/cli/App.java
+++ b/cli/src/main/java/io/kestra/cli/App.java
@@ -4,6 +4,7 @@ import io.kestra.cli.commands.configs.sys.ConfigCommand;
 import io.kestra.cli.commands.flows.FlowCommand;
 import io.kestra.cli.commands.namespaces.NamespaceCommand;
 import io.kestra.cli.commands.plugins.PluginCommand;
+import io.kestra.cli.commands.servers.AbstractServerCommand;
 import io.kestra.cli.commands.servers.ServerCommand;
 import io.kestra.cli.commands.sys.SysCommand;
 import io.kestra.cli.commands.templates.TemplateCommand;
@@ -13,16 +14,14 @@ import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.ApplicationContextBuilder;
 import io.micronaut.context.env.Environment;
 import io.micronaut.core.annotation.Introspected;
+import lombok.extern.slf4j.Slf4j;
 import org.slf4j.bridge.SLF4JBridgeHandler;
 import picocli.CommandLine;
 
 import java.lang.reflect.Field;
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
+import java.util.*;
 import java.util.concurrent.Callable;
 
 @CommandLine.Command(
@@ -44,6 +43,7 @@ import java.util.concurrent.Callable;
         NamespaceCommand.class,
     }
 )
+@Slf4j
 @Introspected
 public class App implements Callable<Integer> {
     public static void main(String[] args) {
@@ -79,9 +79,7 @@ public class App implements Callable<Integer> {
      * @param args args passed to java app
      * @return the application context created
      */
-    protected static ApplicationContext applicationContext(Class<?> mainClass,
-                                                           String[] args) {
-
+    protected static ApplicationContext applicationContext(Class<?> mainClass, String[] args) {
         ApplicationContextBuilder builder = ApplicationContext
             .builder()
             .mainClass(mainClass)
@@ -96,15 +94,15 @@ public class App implements Callable<Integer> {
         Class<?> cls = commandLine.getCommandSpec().userObject().getClass();
 
         if (AbstractCommand.class.isAssignableFrom(cls)) {
-            // if class have propertiesFromConfig, add configuration files
-            builder.properties(getPropertiesFromMethod(cls, "propertiesFromConfig", commandLine.getCommandSpec().userObject()));
-
             Map<String, Object> properties = new HashMap<>();
 
+            // if class have propertiesFromConfig, add configuration files
+            addPropertiesFromMethod(properties, cls, "propertiesFromConfig", commandLine.getCommandSpec().userObject());
             // if class have propertiesOverrides, add force properties for this class
-            Map<String, Object> propertiesOverrides = getPropertiesFromMethod(cls, "propertiesOverrides", null);
-            if (propertiesOverrides != null) {
-                properties.putAll(propertiesOverrides);
+            addPropertiesFromMethod(properties, cls, "propertiesOverrides", null);
+
+            if (AbstractServerCommand.class.isAssignableFrom(cls)) {
+                validateServerCmdConfig(properties);
             }
 
             // custom server configuration
@@ -123,6 +121,34 @@ public class App implements Callable<Integer> {
         return builder.build();
     }
 
+    private static void addPropertiesFromMethod(Map<String, Object> properties,  Class<?> cls, String methodName, Object instance) {
+        Map<String, Object> propertiesFromMethod = getPropertiesFromMethod(cls, methodName, instance);
+        if (propertiesFromMethod != null) {
+            properties.putAll(propertiesFromMethod);
+        }
+    }
+
+    private static void validateServerCmdConfig(Map<String, Object> propertiesFromConfig) {
+        final Map<String, String> requiredProperties = Map.of(
+            "kestra.queue.type", "https://kestra.io/docs/configuration-guide/setup#queue-configuration",
+            "kestra.repository.type", "https://kestra.io/docs/configuration-guide/setup#repository-configuration",
+            "kestra.storage.type", "https://kestra.io/docs/configuration-guide/setup#internal-storage-configuration"
+        );
+
+        final List<Map.Entry<String, String>> missingProperties = requiredProperties.entrySet().stream()
+            .filter((property) -> !propertiesFromConfig.containsKey(property.getKey()))
+            .toList();
+
+        missingProperties.forEach(property -> log.error("""
+            Server configuration requires the '{}' property to be defined.
+            For more details, please follow the official setup guide at: {}""", property.getKey(), property.getValue())
+        );
+
+        if (!missingProperties.isEmpty()) {
+            throw new AbstractServerCommand.ServerCommandException("Incomplete server configuration - missing required properties");
+        }
+    }
+
     @SuppressWarnings("unchecked")
     private static <T> T getPropertiesFromMethod(Class<?> cls, String methodName, Object instance) {
         try {
diff --git a/cli/src/main/java/io/kestra/cli/commands/servers/AbstractServerCommand.java b/cli/src/main/java/io/kestra/cli/commands/servers/AbstractServerCommand.java
index 5e84f4451..68c5798c3 100644
--- a/cli/src/main/java/io/kestra/cli/commands/servers/AbstractServerCommand.java
+++ b/cli/src/main/java/io/kestra/cli/commands/servers/AbstractServerCommand.java
@@ -3,7 +3,18 @@ package io.kestra.cli.commands.servers;
 import io.kestra.cli.AbstractCommand;
 import picocli.CommandLine;
 
+import java.io.Serial;
+
 abstract public class AbstractServerCommand extends AbstractCommand implements ServerCommandInterface {
     @CommandLine.Option(names = {"--port"}, description = "the port to bind")
     Integer serverPort;
+
+    public static class ServerCommandException extends RuntimeException {
+        @Serial
+        private static final long serialVersionUID = 1L;
+
+        public ServerCommandException(String errorMessage) {
+            super(errorMessage);
+        }
+    }
 }
diff --git a/ui/src/components/namespace/Namespaces.vue b/ui/src/components/namespace/Namespaces.vue
index e49926a7c..b6b63f9b7 100644
--- a/ui/src/components/namespace/Namespaces.vue
+++ b/ui/src/components/namespace/Namespaces.vue
@@ -44,7 +44,6 @@
 
 <script setup lang="ts">
     import {onMounted, computed, watch, ref} from "vue";
-    import {useRouter} from "vue-router";
     import {useStore} from "vuex";
     import {ElTree} from "element-plus";
 
@@ -75,7 +74,6 @@
         children?: Node[];
     }
 
-    const router = useRouter();
     const route = computed(() => ({title: t("namespaces")}));
     const user = computed(() => store.state.auth.user);
     const isUserEmpty = computed(() => Object.keys(user.value).length === 0);
@@ -85,10 +83,8 @@
 
     const namespaces = computed(() => store.state.namespace.namespaces as Namespace[]);
     const loadData = () => {
-        const query = filter.value ? {q: filter.value} : undefined;
-
-        router.push({query});
-
+        // TODO: Implement a new endpoint which does not require size limit but returns everything
+        const query = {size: 10000, page: 1, ...(filter.value ? {q: filter.value} : {})}
         store.dispatch("namespace/search", query);
     };
 


Test patch:
diff --git a/cli/src/test/java/AppTest.java b/cli/src/test/java/AppTest.java
index f338283b9..0db7c4cf3 100644
--- a/cli/src/test/java/AppTest.java
+++ b/cli/src/test/java/AppTest.java
@@ -1,6 +1,9 @@
+import io.kestra.cli.commands.servers.AbstractServerCommand;
 import io.micronaut.configuration.picocli.PicocliRunner;
 import io.micronaut.context.ApplicationContext;
 import io.micronaut.context.env.Environment;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
 import org.junit.jupiter.api.Test;
 import io.kestra.cli.App;
 
@@ -9,8 +12,20 @@ import java.io.PrintStream;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.containsString;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 
 public class AppTest {
+    @BeforeAll
+    public static void setUp() {
+        // Don't make any assumptions about $HOME
+        System.setProperty("user.home", "/foo");
+    }
+
+    @AfterAll
+    public static void tearDown() {
+        System.clearProperty("user.home");
+    }
+
     @Test
     public void testHelp() {
         ByteArrayOutputStream out = new ByteArrayOutputStream();
@@ -22,4 +37,9 @@ public class AppTest {
             assertThat(out.toString(), containsString("kestra"));
         }
     }
+
+    @Test
+    public void testSeverCommandValidation() {
+        assertThrows(AbstractServerCommand.ServerCommandException.class, () -> App.main(new String[]{"server", "webserver"}));
+    }
 }