Repository: kestra-io/kestra
Pull request #6642 (2025-01-06 16:12:48)

feat(core): add `taskRunner` output on ScriptOutput to get detailled information on underlying taskrunner

sonarqubecloud[bot]: ## [![Quality Gate Failed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-failed-20px.png 'Quality Gate Failed')](https://sonarcloud.io/dashboard?id=kestra-io_kestra&pullRequest=6642) **Quality Gate failed**  
Failed conditions  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/failed-16px.png '') [29.9% Coverage on New Code](https://sonarcloud.io/component_measures?id=kestra-io_kestra&pullRequest=6642&metric=new_coverage&view=list) (required ≥ 80%)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=kestra-io_kestra&pullRequest=6642)



Patch:
diff --git a/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java b/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
index 141ef8164..f53b42012 100644
--- a/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
+++ b/core/src/main/java/io/kestra/core/docs/DocumentationGenerator.java
@@ -70,7 +70,8 @@ public class DocumentationGenerator {
         result.addAll(this.generate(registeredPlugin, registeredPlugin.getTasks(), Task.class, "tasks"));
         result.addAll(this.generate(registeredPlugin, registeredPlugin.getTriggers(), AbstractTrigger.class, "triggers"));
         result.addAll(this.generate(registeredPlugin, registeredPlugin.getConditions(), Condition.class, "conditions"));
-        result.addAll(this.generate(registeredPlugin, registeredPlugin.getTaskRunners(), TaskRunner.class, "task-runners"));
+        //noinspection unchecked
+        result.addAll(this.generate(registeredPlugin, registeredPlugin.getTaskRunners(), (Class) TaskRunner.class, "task-runners"));
         result.addAll(this.generate(registeredPlugin, registeredPlugin.getLogShippers(), LogShipper.class, "log-shipper"));
 
         result.addAll(guides(registeredPlugin));
diff --git a/core/src/main/java/io/kestra/core/models/collectors/FlowUsage.java b/core/src/main/java/io/kestra/core/models/collectors/FlowUsage.java
index 75987aca8..d195f4757 100644
--- a/core/src/main/java/io/kestra/core/models/collectors/FlowUsage.java
+++ b/core/src/main/java/io/kestra/core/models/collectors/FlowUsage.java
@@ -85,7 +85,7 @@ public class FlowUsage {
             })
             .map(t -> {
                 try {
-                    TaskRunner taskRunner = (TaskRunner) t.getClass().getMethod("getTaskRunner").invoke(t);
+                    TaskRunner<?> taskRunner = (TaskRunner<?>) t.getClass().getMethod("getTaskRunner").invoke(t);
                     return taskRunner != null ? taskRunner.getType() : null;
                 } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
                     return null;
diff --git a/core/src/main/java/io/kestra/core/models/flows/FlowForExecution.java b/core/src/main/java/io/kestra/core/models/flows/FlowForExecution.java
index f05a6a955..9458351b7 100644
--- a/core/src/main/java/io/kestra/core/models/flows/FlowForExecution.java
+++ b/core/src/main/java/io/kestra/core/models/flows/FlowForExecution.java
@@ -21,6 +21,9 @@ public class FlowForExecution extends AbstractFlow {
     @NotEmpty
     List<TaskForExecution> tasks;
 
+    @Valid
+    List<TaskForExecution> errors;
+
     @Valid
     List<AbstractTriggerForExecution> triggers;
 
@@ -32,6 +35,7 @@ public class FlowForExecution extends AbstractFlow {
             .revision(flow.getRevision())
             .inputs(flow.getInputs())
             .tasks(flow.getTasks().stream().map(TaskForExecution::of).toList())
+            .errors(ListUtils.emptyOnNull(flow.getErrors()).stream().map(TaskForExecution::of).toList())
             .triggers(ListUtils.emptyOnNull(flow.getTriggers()).stream().map(AbstractTriggerForExecution::of).toList())
             .disabled(flow.isDisabled())
             .deleted(flow.isDeleted())
diff --git a/core/src/main/java/io/kestra/core/models/tasks/runners/RunnerResult.java b/core/src/main/java/io/kestra/core/models/tasks/runners/RunnerResult.java
deleted file mode 100644
index 7e0cfefc5..000000000
--- a/core/src/main/java/io/kestra/core/models/tasks/runners/RunnerResult.java
+++ /dev/null
@@ -1,11 +0,0 @@
-package io.kestra.core.models.tasks.runners;
-
-import lombok.AllArgsConstructor;
-import lombok.Getter;
-
-@AllArgsConstructor
-@Getter
-public class RunnerResult {
-    private int exitCode;
-    private AbstractLogConsumer logConsumer;
-}
diff --git a/core/src/main/java/io/kestra/core/models/tasks/runners/ScriptService.java b/core/src/main/java/io/kestra/core/models/tasks/runners/ScriptService.java
index 5de4a530e..ea43adbc5 100644
--- a/core/src/main/java/io/kestra/core/models/tasks/runners/ScriptService.java
+++ b/core/src/main/java/io/kestra/core/models/tasks/runners/ScriptService.java
@@ -85,7 +85,7 @@ public final class ScriptService {
         List<String> commands,
         boolean replaceWithRelativePath
     ) throws IOException, IllegalVariableEvaluationException {
-        return commands
+        return ListUtils.emptyOnNull(commands)
             .stream()
             .map(throwFunction(c -> runContext.render(c, additionalVars)))
             .map(throwFunction(c -> ScriptService.replaceInternalStorage(runContext, c, replaceWithRelativePath)))
diff --git a/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunner.java b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunner.java
index a680bbe5f..3a7653776 100644
--- a/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunner.java
+++ b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunner.java
@@ -32,7 +32,7 @@ import static io.kestra.core.utils.WindowsUtils.windowsToUnixPath;
 @Getter
 @NoArgsConstructor
 @JsonInclude(JsonInclude.Include.NON_DEFAULT)
-public abstract class TaskRunner implements Plugin, WorkerJobLifecycle {
+public abstract class TaskRunner<T extends TaskRunnerDetailResult> implements Plugin, WorkerJobLifecycle {
     @NotBlank
     @Pattern(regexp="\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*(\\.\\p{javaJavaIdentifierStart}\\p{javaJavaIdentifierPart}*)*")
     protected String type;
@@ -62,7 +62,7 @@ public abstract class TaskRunner implements Plugin, WorkerJobLifecycle {
      * For remote task runner (like Kubernetes or in a cloud provider), <code>filesToUpload</code> must be used to upload input and namespace files to the runner,
      * and <code>filesToDownload</code> must be used to download output files from the runner.
      */
-    public abstract RunnerResult run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception;
+    public abstract TaskRunnerResult<T> run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception;
 
     public Map<String, Object> additionalVars(RunContext runContext, TaskCommands taskCommands) throws IllegalVariableEvaluationException {
         if (this.additionalVars == null) {
diff --git a/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerDetailResult.java b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerDetailResult.java
new file mode 100644
index 000000000..6b08df445
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerDetailResult.java
@@ -0,0 +1,10 @@
+package io.kestra.core.models.tasks.runners;
+
+import io.kestra.core.models.tasks.Output;
+import lombok.Getter;
+import lombok.experimental.SuperBuilder;
+
+@Getter
+@SuperBuilder
+public class TaskRunnerDetailResult implements Output {
+}
diff --git a/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerResult.java b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerResult.java
new file mode 100644
index 000000000..764014ffe
--- /dev/null
+++ b/core/src/main/java/io/kestra/core/models/tasks/runners/TaskRunnerResult.java
@@ -0,0 +1,24 @@
+package io.kestra.core.models.tasks.runners;
+
+import io.kestra.core.models.tasks.Output;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@AllArgsConstructor
+@Getter
+@Builder
+@NoArgsConstructor
+public class TaskRunnerResult<T extends TaskRunnerDetailResult> implements Output {
+    private int exitCode;
+    private AbstractLogConsumer logConsumer;
+    private T details;
+
+    @SuppressWarnings("unchecked")
+    public TaskRunnerResult(int exitCode, AbstractLogConsumer logConsumer) {
+        this.exitCode = exitCode;
+        this.logConsumer = logConsumer;
+        this.details = (T) TaskRunnerDetailResult.builder().build();
+    }
+}
diff --git a/core/src/main/java/io/kestra/core/plugins/PluginScanner.java b/core/src/main/java/io/kestra/core/plugins/PluginScanner.java
index 70ff15eaf..7266508b4 100644
--- a/core/src/main/java/io/kestra/core/plugins/PluginScanner.java
+++ b/core/src/main/java/io/kestra/core/plugins/PluginScanner.java
@@ -101,7 +101,7 @@ public class PluginScanner {
         List<Class<? extends Condition>> conditions = new ArrayList<>();
         List<Class<? extends StorageInterface>> storages = new ArrayList<>();
         List<Class<? extends SecretPluginInterface>> secrets = new ArrayList<>();
-        List<Class<? extends TaskRunner>> taskRunners = new ArrayList<>();
+        List<Class<? extends TaskRunner<?>>> taskRunners = new ArrayList<>();
         List<Class<? extends AppPluginInterface>> apps = new ArrayList<>();
         List<Class<? extends AppBlockInterface>> appBlocks = new ArrayList<>();
         List<Class<? extends Chart<?>>> charts = new ArrayList<>();
@@ -142,9 +142,10 @@ public class PluginScanner {
                         log.debug("Loading SecretPlugin plugin: '{}'", plugin.getClass());
                         secrets.add(storage.getClass());
                     }
-                    case TaskRunner runner -> {
+                    case TaskRunner<?> runner -> {
                         log.debug("Loading TaskRunner plugin: '{}'", plugin.getClass());
-                        taskRunners.add(runner.getClass());
+                        //noinspection unchecked
+                        taskRunners.add((Class<? extends TaskRunner<?>>) runner.getClass());
                     }
                     case AppPluginInterface app -> {
                         log.debug("Loading AppPlugin plugin: '{}'", plugin.getClass());
diff --git a/core/src/main/java/io/kestra/core/plugins/RegisteredPlugin.java b/core/src/main/java/io/kestra/core/plugins/RegisteredPlugin.java
index 8af0a2111..fe0f83f90 100644
--- a/core/src/main/java/io/kestra/core/plugins/RegisteredPlugin.java
+++ b/core/src/main/java/io/kestra/core/plugins/RegisteredPlugin.java
@@ -40,7 +40,7 @@ public class RegisteredPlugin {
     private final List<Class<? extends Condition>> conditions;
     private final List<Class<? extends StorageInterface>> storages;
     private final List<Class<? extends SecretPluginInterface>> secrets;
-    private final List<Class<? extends TaskRunner>> taskRunners;
+    private final List<Class<? extends TaskRunner<?>>> taskRunners;
     private final List<Class<? extends AppPluginInterface>> apps;
     private final List<Class<? extends AppBlockInterface>> appBlocks;
     private final List<Class<? extends Chart<?>>> charts;
diff --git a/core/src/main/java/io/kestra/plugin/core/dashboard/chart/tables/TableColumnDescriptor.java b/core/src/main/java/io/kestra/plugin/core/dashboard/chart/tables/TableColumnDescriptor.java
index 000ca3e4b..c8b1b981f 100644
--- a/core/src/main/java/io/kestra/plugin/core/dashboard/chart/tables/TableColumnDescriptor.java
+++ b/core/src/main/java/io/kestra/plugin/core/dashboard/chart/tables/TableColumnDescriptor.java
@@ -2,6 +2,7 @@ package io.kestra.plugin.core.dashboard.chart.tables;
 
 import io.kestra.core.models.dashboards.ColumnDescriptor;
 import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 import lombok.NoArgsConstructor;
@@ -13,6 +14,7 @@ import lombok.experimental.SuperBuilder;
 @EqualsAndHashCode
 @Schema
 public class TableColumnDescriptor<F extends Enum<F>> extends ColumnDescriptor<F> {
+    @Builder.Default
     private Alignment columnAlignment = Alignment.LEFT;
 
     enum Alignment {
diff --git a/core/src/main/java/io/kestra/plugin/core/runner/Process.java b/core/src/main/java/io/kestra/plugin/core/runner/Process.java
index bcd5da69f..749e5f9e8 100644
--- a/core/src/main/java/io/kestra/plugin/core/runner/Process.java
+++ b/core/src/main/java/io/kestra/plugin/core/runner/Process.java
@@ -113,7 +113,7 @@ tasks:
         )
     }
 )
-public class Process extends TaskRunner {
+public class Process extends TaskRunner<TaskRunnerDetailResult> {
 
     /**
      * Convenient default instance to be used as task default value for a 'taskRunner' property.
@@ -123,7 +123,7 @@ public class Process extends TaskRunner {
     }
 
     @Override
-    public RunnerResult run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception {
+    public TaskRunnerResult<TaskRunnerDetailResult> run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception {
         Logger logger = runContext.logger();
         AbstractLogConsumer defaultLogConsumer = taskCommands.getLogConsumer();
 
@@ -157,7 +157,7 @@ public class Process extends TaskRunner {
                 logger.debug("Command succeed with exit code {}", exitCode);
             }
 
-            return new RunnerResult(exitCode, defaultLogConsumer);
+            return new TaskRunnerResult<>(exitCode, defaultLogConsumer);
         } catch (InterruptedException e) {
             logger.warn("Killing process {} for InterruptedException", pid);
             killDescendantsOf(process.toHandle(), logger);
diff --git a/script/src/main/java/io/kestra/plugin/scripts/exec/AbstractExecScript.java b/script/src/main/java/io/kestra/plugin/scripts/exec/AbstractExecScript.java
index fe2d11881..bdc0f8e50 100644
--- a/script/src/main/java/io/kestra/plugin/scripts/exec/AbstractExecScript.java
+++ b/script/src/main/java/io/kestra/plugin/scripts/exec/AbstractExecScript.java
@@ -49,7 +49,7 @@ public abstract class AbstractExecScript extends Task implements RunnableTask<Sc
     @PluginProperty
     @Builder.Default
     @Valid
-    protected TaskRunner taskRunner = Docker.builder()
+    protected TaskRunner<?> taskRunner = Docker.builder()
         .type(Docker.class.getName())
         .build();
 
diff --git a/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/models/ScriptOutput.java b/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/models/ScriptOutput.java
index afa960ef4..65d9e7d5d 100644
--- a/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/models/ScriptOutput.java
+++ b/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/models/ScriptOutput.java
@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonInclude;
 import io.kestra.core.models.annotations.PluginProperty;
 import io.kestra.core.models.flows.State;
 import io.kestra.core.models.tasks.Output;
+import io.kestra.core.models.tasks.runners.TaskRunnerDetailResult;
 import io.swagger.v3.oas.annotations.media.Schema;
 import lombok.Builder;
 import lombok.Getter;
@@ -44,6 +45,8 @@ public class ScriptOutput implements Output {
     @JsonIgnore
     private Boolean warningOnStdErr;
 
+    private TaskRunnerDetailResult taskRunner;
+
     @Override
     public Optional<State.Type> finalState() {
         return this.warningOnStdErr != null && this.warningOnStdErr && this.stdErrLineCount > 0 ? Optional.of(State.Type.WARNING) : Output.super.finalState();
diff --git a/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/runners/CommandsWrapper.java b/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/runners/CommandsWrapper.java
index 225b8fd6e..6fb73bd78 100644
--- a/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/runners/CommandsWrapper.java
+++ b/script/src/main/java/io/kestra/plugin/scripts/exec/scripts/runners/CommandsWrapper.java
@@ -56,7 +56,7 @@ public class CommandsWrapper implements TaskCommands {
     private String containerImage;
 
     @With
-    private TaskRunner taskRunner;
+    private TaskRunner<?> taskRunner;
 
     @With
     private DockerOptions dockerOptions;
@@ -131,7 +131,7 @@ public class CommandsWrapper implements TaskCommands {
         return this;
     }
 
-    public ScriptOutput run() throws Exception {
+    public <T extends TaskRunnerDetailResult> ScriptOutput run() throws Exception {
         if (this.namespaceFiles != null && !Boolean.FALSE.equals(runContext.render(this.namespaceFiles.getEnabled()).as(Boolean.class).orElse(true))) {
 
             List<NamespaceFile> matchedNamespaceFiles = runContext.storage()
@@ -147,7 +147,7 @@ public class CommandsWrapper implements TaskCommands {
                 }));
         }
 
-        TaskRunner realTaskRunner = this.getTaskRunner();
+        TaskRunner<T> realTaskRunner = this.getTaskRunner();
         if (this.inputFiles != null) {
             FilesService.inputFiles(runContext, realTaskRunner.additionalVars(runContext, this), this.inputFiles);
         }
@@ -157,17 +157,25 @@ public class CommandsWrapper implements TaskCommands {
         RunContext taskRunnerRunContext = initializer.forPlugin(((DefaultRunContext) runContext).clone(), realTaskRunner);
         this.commands = this.render(runContext, commands);
 
-        var outputBuilder = ScriptOutput.builder().warningOnStdErr(this.warningOnStdErr);
+        ScriptOutput.ScriptOutputBuilder scriptOutputBuilder = ScriptOutput.builder()
+            .warningOnStdErr(this.warningOnStdErr);
+
         try {
-            RunnerResult runnerResult = realTaskRunner.run(taskRunnerRunContext, this, this.outputFiles);
-            return outputBuilder.exitCode(runnerResult.getExitCode())
-                .stdOutLineCount(runnerResult.getLogConsumer().getStdOutCount())
-                .stdErrLineCount(runnerResult.getLogConsumer().getStdErrCount())
-                .vars(runnerResult.getLogConsumer().getOutputs())
+            TaskRunnerResult<T> taskRunnerResult = realTaskRunner.run(taskRunnerRunContext, this, this.outputFiles);
+            scriptOutputBuilder.exitCode(taskRunnerResult.getExitCode())
                 .outputFiles(getOutputFiles(taskRunnerRunContext))
-                .build();
+                .taskRunner(taskRunnerResult.getDetails());
+
+            if (taskRunnerResult.getLogConsumer() != null) {
+                scriptOutputBuilder
+                    .stdOutLineCount(taskRunnerResult.getLogConsumer().getStdOutCount())
+                    .stdErrLineCount(taskRunnerResult.getLogConsumer().getStdErrCount())
+                    .vars(taskRunnerResult.getLogConsumer().getOutputs());
+            }
+
+            return scriptOutputBuilder.build();
         } catch (TaskException e) {
-            var output = outputBuilder.exitCode(e.getExitCode())
+            var output = scriptOutputBuilder.exitCode(e.getExitCode())
                 .stdOutLineCount(e.getStdOutCount())
                 .stdErrLineCount(e.getStdErrCount())
                 .vars(e.getLogConsumer() != null ? e.getLogConsumer().getOutputs() : null)
@@ -189,20 +197,21 @@ public class CommandsWrapper implements TaskCommands {
         return outputFiles;
     }
 
-    public TaskRunner getTaskRunner() {
+    @SuppressWarnings("unchecked")
+    public <T extends TaskRunnerDetailResult> TaskRunner<T> getTaskRunner() {
         if (runnerType != null) {
             return switch (runnerType) {
-                case DOCKER -> Docker.from(dockerOptions);
-                case PROCESS -> new Process();
+                case DOCKER -> (TaskRunner<T>) Docker.from(dockerOptions);
+                case PROCESS -> (TaskRunner<T>) new Process();
             };
         }
 
         // special case to take into account the deprecated dockerOptions if set
         if (taskRunner instanceof Docker && dockerOptions != null) {
-            return Docker.from(dockerOptions);
+            return (TaskRunner<T>) Docker.from(dockerOptions);
         }
 
-        return taskRunner;
+        return (TaskRunner<T>) taskRunner;
     }
 
     public Boolean getEnableOutputDirectory() {
@@ -226,7 +235,7 @@ public class CommandsWrapper implements TaskCommands {
     }
 
     public String render(RunContext runContext, String command, List<String> internalStorageLocalFiles) throws IllegalVariableEvaluationException, IOException {
-        TaskRunner taskRunner = this.getTaskRunner();
+        TaskRunner<?> taskRunner = this.getTaskRunner();
         return ScriptService.replaceInternalStorage(
             this.runContext,
             taskRunner.additionalVars(runContext, this),
@@ -236,7 +245,7 @@ public class CommandsWrapper implements TaskCommands {
     }
 
     public List<String> render(RunContext runContext, List<String> commands) throws IllegalVariableEvaluationException, IOException {
-        TaskRunner taskRunner = this.getTaskRunner();
+        TaskRunner<?> taskRunner = this.getTaskRunner();
         return ScriptService.replaceInternalStorage(
             this.runContext,
             taskRunner.additionalVars(runContext, this),
diff --git a/script/src/main/java/io/kestra/plugin/scripts/runner/docker/Docker.java b/script/src/main/java/io/kestra/plugin/scripts/runner/docker/Docker.java
index 6b58274e8..1bfd39681 100644
--- a/script/src/main/java/io/kestra/plugin/scripts/runner/docker/Docker.java
+++ b/script/src/main/java/io/kestra/plugin/scripts/runner/docker/Docker.java
@@ -9,7 +9,6 @@ import com.github.dockerjava.api.model.*;
 import com.github.dockerjava.core.DefaultDockerClientConfig;
 import com.github.dockerjava.core.DockerClientConfig;
 import com.github.dockerjava.core.NameParser;
-import com.github.dockerjava.transport.DomainSocket;
 import com.sun.jna.LastErrorException;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.models.annotations.Example;
@@ -144,7 +143,7 @@ import static io.kestra.core.utils.WindowsUtils.windowsToUnixPath;
         ),
     }
 )
-public class Docker extends TaskRunner {
+public class Docker extends TaskRunner<Docker.DockerTaskRunnerDetailResult> {
     private static final ReadableBytesTypeConverter READABLE_BYTES_TYPE_CONVERTER = new ReadableBytesTypeConverter();
     private static final Pattern NEWLINE_PATTERN = Pattern.compile("([^\\r\\n]+)[\\r\\n]+");
 
@@ -200,6 +199,16 @@ public class Docker extends TaskRunner {
     @PluginProperty(dynamic = true)
     protected String networkMode;
 
+    @Schema(
+        title = "List of port bindings.",
+        description = "Corresponds to the --publish (-p) option of the docker run CLI command using the format `ip:dockerHostPort:containerPort/protocol`. Possible example : \n" +
+            "- 8080:80/udp" +
+            "- 127.0.0.1:8080:80" +
+            "- 127.0.0.1:8080:80/udp"
+    )
+    @PluginProperty(dynamic = true)
+    protected List<String> portBindings;
+
     @Schema(
         title = "List of volumes to mount.",
         description = """
@@ -290,6 +299,13 @@ public class Docker extends TaskRunner {
     @PluginProperty
     private Boolean delete = true;
 
+    @Builder.Default
+    @Schema(
+        title = "Whether to wait for the container to exit."
+    )
+    @PluginProperty
+    private final Boolean wait = true;
+
     /**
      * Convenient default instance to be used as task default value for a 'taskRunner' property.
      **/
@@ -322,9 +338,8 @@ public class Docker extends TaskRunner {
             .build();
     }
 
-
     @Override
-    public RunnerResult run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception {
+    public TaskRunnerResult<DockerTaskRunnerDetailResult> run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) throws Exception {
         if (taskCommands.getContainerImage() == null && this.image == null) {
             throw new IllegalArgumentException("This task runner needs the `containerImage` property to be set");
         }
@@ -422,6 +437,14 @@ public class Docker extends TaskRunner {
                 );
             }
 
+            if (!wait) {
+                return TaskRunnerResult.<DockerTaskRunnerDetailResult>builder()
+                    .exitCode(0)
+                    .logConsumer(defaultLogConsumer)
+                    .details(DockerTaskRunnerDetailResult.builder().containerId(exec.getId()).build())
+                    .build();
+            }
+
             // register the runnable to be used for killing the container.
             onKill(() -> kill(dockerClient, exec.getId(), logger));
 
@@ -503,7 +526,11 @@ public class Docker extends TaskRunner {
                     downloadOutputFiles(exec.getId(), dockerClient, runContext, taskCommands);
                 }
 
-                return new RunnerResult(exitCode, defaultLogConsumer);
+                return TaskRunnerResult.<DockerTaskRunnerDetailResult>builder()
+                    .exitCode(exitCode)
+                    .logConsumer(defaultLogConsumer)
+                    .details(DockerTaskRunnerDetailResult.builder().containerId(exec.getId()).build())
+                    .build();
             } finally {
                 try {
                     // kill container if it's still running, this means there was an exception and the container didn't
@@ -744,6 +771,13 @@ public class Docker extends TaskRunner {
             hostConfig.withNetworkMode(runContext.render(this.getNetworkMode(), additionalVars));
         }
 
+        if (this.getPortBindings() != null) {
+            hostConfig.withPortBindings(runContext.render(this.getPortBindings(), additionalVars)
+                .stream()
+                .map(PortBinding::parse)
+                .toList()
+            );
+        }
 
         return container
             .withHostConfig(hostConfig)
@@ -799,6 +833,12 @@ public class Docker extends TaskRunner {
         }
     }
 
+    @SuperBuilder
+    @Getter
+    public static class DockerTaskRunnerDetailResult extends TaskRunnerDetailResult {
+        private String containerId;
+    }
+
     public enum FileHandlingStrategy {
         MOUNT,
         VOLUME
diff --git a/script/src/main/java/io/kestra/plugin/scripts/runner/docker/DockerService.java b/script/src/main/java/io/kestra/plugin/scripts/runner/docker/DockerService.java
index 30783f820..9684e6a91 100644
--- a/script/src/main/java/io/kestra/plugin/scripts/runner/docker/DockerService.java
+++ b/script/src/main/java/io/kestra/plugin/scripts/runner/docker/DockerService.java
@@ -1,6 +1,7 @@
 package io.kestra.plugin.scripts.runner.docker;
 
 import com.github.dockerjava.api.DockerClient;
+import com.github.dockerjava.core.DefaultDockerClientConfig;
 import com.github.dockerjava.core.DockerClientBuilder;
 import com.github.dockerjava.core.DockerClientConfig;
 import com.github.dockerjava.core.NameParser;
@@ -50,6 +51,26 @@ public class DockerService {
         return "unix:///dind/docker.sock";
     }
 
+    public static DockerClient client(RunContext runContext, @Nullable String host, @Nullable Object config, @Nullable Credentials credentials, @Nullable String image) throws IOException, IllegalVariableEvaluationException {
+        DefaultDockerClientConfig.Builder dockerClientConfigBuilder = DefaultDockerClientConfig.createDefaultConfigBuilder()
+            .withDockerHost(DockerService.findHost(runContext, host));
+
+        if (config != null || credentials != null) {
+            Path configPath = DockerService.createConfig(
+                runContext,
+                config,
+                credentials != null ? List.of(credentials) : null,
+                image
+            );
+
+            dockerClientConfigBuilder.withDockerConfig(configPath.toFile().getAbsolutePath());
+        }
+
+        DockerClientConfig dockerClientConfig = dockerClientConfigBuilder.build();
+
+        return DockerService.client(dockerClientConfig);
+    }
+
     @SuppressWarnings("unchecked")
     public static Path createConfig(RunContext runContext, @Nullable Object config, @Nullable List<Credentials> credentials, @Nullable String image) throws IllegalVariableEvaluationException, IOException {
         Map<String, Object> finalConfig = new HashMap<>();
diff --git a/ui/src/components/executions/outputs/Wrapper.vue b/ui/src/components/executions/outputs/Wrapper.vue
index 672085b0f..24f48c352 100644
--- a/ui/src/components/executions/outputs/Wrapper.vue
+++ b/ui/src/components/executions/outputs/Wrapper.vue
@@ -417,6 +417,7 @@
         const mapped = {};
 
         getTaskIcons(store.state.execution?.flow?.tasks || [], mapped);
+        getTaskIcons(store.state.execution?.flow?.errors || [], mapped);
 
         return mapped;
     });


Test patch:
diff --git a/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java b/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
index 763c5ecc4..38db37c9b 100644
--- a/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
+++ b/core/src/test/java/io/kestra/core/docs/ClassPluginDocumentationTest.java
@@ -117,7 +117,7 @@ class ClassPluginDocumentationTest {
             PluginScanner pluginScanner = new PluginScanner(ClassPluginDocumentationTest.class.getClassLoader());
             RegisteredPlugin scan = pluginScanner.scan();
 
-            ClassPluginDocumentation<? extends TaskRunner> doc = ClassPluginDocumentation.of(jsonSchemaGenerator, scan, Process.class, null);
+            ClassPluginDocumentation<? extends TaskRunner<?>> doc = ClassPluginDocumentation.of(jsonSchemaGenerator, scan, Process.class, null);
 
             assertThat((Map<?, ?>) doc.getPropertiesSchema().get("properties"), anEmptyMap());
             assertThat(doc.getCls(), is("io.kestra.plugin.core.runner.Process"));
diff --git a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
index fff0f58c4..280207ec2 100644
--- a/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
+++ b/core/src/test/java/io/kestra/core/docs/DocumentationGeneratorTest.java
@@ -180,7 +180,7 @@ class DocumentationGeneratorTest {
         RegisteredPlugin scan = pluginScanner.scan();
         Class<Process> processTaskRunner = scan.findClass(Process.class.getName()).orElseThrow();
 
-        ClassPluginDocumentation<? extends TaskRunner> doc = ClassPluginDocumentation.of(jsonSchemaGenerator, scan, processTaskRunner, TaskRunner.class);
+        ClassPluginDocumentation<Process> doc = ClassPluginDocumentation.of(jsonSchemaGenerator, scan, processTaskRunner, Process.class);
 
         String render = DocumentationGenerator.render(doc);
 
diff --git a/core/src/test/java/io/kestra/core/models/tasks/runners/TaskRunnerTest.java b/core/src/test/java/io/kestra/core/models/tasks/runners/TaskRunnerTest.java
index f4ccf169f..f7f99fe7e 100644
--- a/core/src/test/java/io/kestra/core/models/tasks/runners/TaskRunnerTest.java
+++ b/core/src/test/java/io/kestra/core/models/tasks/runners/TaskRunnerTest.java
@@ -30,7 +30,7 @@ public class TaskRunnerTest {
 
     @Test
     void additionalVarsAndEnv() throws IllegalVariableEvaluationException {
-        TaskRunner taskRunner = new TaskRunnerAdditional(true);
+        TaskRunner<?> taskRunner = new TaskRunnerAdditional(true);
         TaskCommands taskCommands = new TaskCommandsAdditional();
 
         Map<String, Object> contextVariables = Map.of(
@@ -75,7 +75,7 @@ public class TaskRunnerTest {
         )));
     }
 
-    private static class TaskRunnerAdditional extends TaskRunner {
+    private static class TaskRunnerAdditional extends TaskRunner<TaskRunnerDetailResult> {
 
         public static final String RUNNER_BUCKET_PATH = "{{runnerBucketPath}}";
         public static final String RUNNER_WORKING_DIR = "runnerWorkingDir";
@@ -97,7 +97,7 @@ public class TaskRunnerTest {
         }
 
         @Override
-        public RunnerResult run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) {
+        public TaskRunnerResult<TaskRunnerDetailResult> run(RunContext runContext, TaskCommands taskCommands, List<String> filesToDownload) {
             return null;
         }
 
diff --git a/core/src/test/java/io/kestra/core/models/tasks/runners/types/ProcessTest.java b/core/src/test/java/io/kestra/core/models/tasks/runners/types/ProcessTest.java
index 1a98284f6..75bc6a304 100644
--- a/core/src/test/java/io/kestra/core/models/tasks/runners/types/ProcessTest.java
+++ b/core/src/test/java/io/kestra/core/models/tasks/runners/types/ProcessTest.java
@@ -7,7 +7,7 @@ import io.kestra.plugin.core.runner.Process;
 class ProcessTest extends AbstractTaskRunnerTest {
 
     @Override
-    protected TaskRunner taskRunner() {
+    protected TaskRunner<?> taskRunner() {
         return new Process();
     }
 }
\ No newline at end of file
diff --git a/script/src/test/java/io/kestra/plugin/scripts/runner/docker/DockerTest.java b/script/src/test/java/io/kestra/plugin/scripts/runner/docker/DockerTest.java
index 96a072d5e..8f7a71aef 100644
--- a/script/src/test/java/io/kestra/plugin/scripts/runner/docker/DockerTest.java
+++ b/script/src/test/java/io/kestra/plugin/scripts/runner/docker/DockerTest.java
@@ -6,7 +6,7 @@ import io.kestra.core.models.tasks.runners.TaskRunner;
 
 class DockerTest extends AbstractTaskRunnerTest {
     @Override
-    protected TaskRunner taskRunner() {
+    protected TaskRunner<?> taskRunner() {
         return Docker.builder().image("centos").build();
     }
 }
\ No newline at end of file
diff --git a/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java b/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
index 8544c9d24..2da157b1d 100644
--- a/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
+++ b/script/src/test/java/io/kestra/plugin/scripts/runners/LogConsumerTest.java
@@ -2,7 +2,7 @@ package io.kestra.plugin.scripts.runners;
 
 import com.google.common.collect.ImmutableMap;
 import io.kestra.core.models.executions.LogEntry;
-import io.kestra.core.models.tasks.runners.RunnerResult;
+import io.kestra.core.models.tasks.runners.TaskRunnerResult;
 import io.kestra.core.models.tasks.runners.TaskCommands;
 import io.kestra.core.models.tasks.Task;
 import io.kestra.core.queues.QueueFactoryInterface;
@@ -59,7 +59,9 @@ public class LogConsumerTest {
             "echo \"::{\\\"outputs\\\":{\\\"someOutput\\\":\\\"" + outputValue + "\\\"}}::\"\n" +
                 "echo -n another line"
         ));
-        RunnerResult run = Docker.from(DockerOptions.builder().image("alpine").build()).run(
+        var run = Docker.from(DockerOptions.builder()
+            .image("alpine")
+            .build()).run(
             runContext,
             taskCommands,
             Collections.emptyList()
@@ -84,7 +86,7 @@ public class LogConsumerTest {
             "echo " + outputValue +
                 "echo -n another line"
         ));
-        RunnerResult run = Docker.from(DockerOptions.builder().image("alpine").build()).run(
+        var run = Docker.from(DockerOptions.builder().image("alpine").build()).run(
             runContext,
             taskCommands,
             Collections.emptyList()
@@ -108,7 +110,7 @@ public class LogConsumerTest {
                 echo '::{"logs": [{"level":"TRACE","message":"Hello Trace"}, {"level":"TRACE","message":"Hello Trace 2"}]}::'
             """
         ));
-        RunnerResult run = Docker.from(DockerOptions.builder().image("alpine").build()).run(
+        var run = Docker.from(DockerOptions.builder().image("alpine").build()).run(
             runContext,
             taskCommands,
             Collections.emptyList()
diff --git a/tests/src/main/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java b/tests/src/main/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
index bf4ef4be3..5b787a62f 100644
--- a/tests/src/main/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
+++ b/tests/src/main/java/io/kestra/core/models/tasks/runners/AbstractTaskRunnerTest.java
@@ -5,7 +5,6 @@ import io.kestra.core.models.executions.TaskRun;
 import io.kestra.core.models.flows.Flow;
 import io.kestra.core.models.flows.State;
 import io.kestra.core.models.tasks.Task;
-import io.kestra.core.models.tasks.runners.*;
 import io.kestra.core.runners.FilesService;
 import io.kestra.core.runners.RunContext;
 import io.kestra.core.runners.RunContextFactory;
@@ -88,7 +87,7 @@ public abstract class AbstractTaskRunnerTest {
         // This is purely to showcase that no logs is sent as STDERR for now as CloudWatch doesn't seem to send such information.
         Map<String, Boolean> logsWithIsStdErr = new HashMap<>();
 
-        TaskRunner taskRunner = taskRunner();
+        TaskRunner<?> taskRunner = taskRunner();
 
         Mockito.when(commands.getLogConsumer()).thenReturn(new AbstractLogConsumer() {
             @Override
@@ -116,7 +115,7 @@ public abstract class AbstractTaskRunnerTest {
         Mockito.when(commands.getCommands()).thenReturn(renderedCommands);
 
         List<String> filesToDownload = List.of("output.txt");
-        RunnerResult run = taskRunner.run(runContext, commands, filesToDownload);
+        TaskRunnerResult<?> run = taskRunner.run(runContext, commands, filesToDownload);
 
         Map<String, URI> outputFiles = ScriptService.uploadOutputFiles(runContext, commands.getOutputDirectory());
         outputFiles.putAll(FilesService.outputFiles(runContext, filesToDownload));
@@ -191,7 +190,7 @@ public abstract class AbstractTaskRunnerTest {
         return runContextFactory.of(mergedVars);
     }
 
-    protected abstract TaskRunner taskRunner();
+    protected abstract TaskRunner<?> taskRunner();
 
     protected String defaultImage() {
         return "ubuntu";