Repository: kestra-io/kestra (java)

Issue:
The Executions endpoint should return a URL allowing to follow the Execution progress from the UI
### Feature description

Currently, you can create a flow:

```yaml
id: myflow
namespace: company.myteam
tasks:
  - id: hello
    type: io.kestra.plugin.scripts.shell.Commands
    commands:
      - sleep 30
    taskRunner:
      type: io.kestra.plugin.core.runner.Process
```

Then, you can create an Execution of this flow from anywhere via an API call:

```bash
curl -X POST http://localhost:8080/api/v1/executions/company.myteam/myflow
```

This endpoint is even capable of waiting until the Execution finishes by adding `?wait=true`:

```bash
curl -X POST http://localhost:8080/api/v1/executions/company.myteam/myflow?wait=true
```

What's missing is a UI URL allowing users to click on the URL to follow the Execution progress.



Pull request description:
feat(core, webserver): add an URL pointing to the UI to the create ex…
…ecution endpoint

Fixes #4256

Patch:
diff --git a/core/src/main/java/io/kestra/core/models/executions/Execution.java b/core/src/main/java/io/kestra/core/models/executions/Execution.java
index 7467a7371..183a8b19a 100644
--- a/core/src/main/java/io/kestra/core/models/executions/Execution.java
+++ b/core/src/main/java/io/kestra/core/models/executions/Execution.java
@@ -26,9 +26,8 @@ import io.micronaut.core.annotation.Nullable;
 import io.swagger.v3.oas.annotations.Hidden;
 import jakarta.validation.constraints.NotNull;
 import jakarta.validation.constraints.Pattern;
-import lombok.Builder;
-import lombok.Value;
-import lombok.With;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
 import lombok.extern.slf4j.Slf4j;
 
 import java.time.Instant;
@@ -40,9 +39,13 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import java.util.zip.CRC32;
 
-@Value
 @Builder(toBuilder = true)
 @Slf4j
+@Getter
+@FieldDefaults(makeFinal=true, level=AccessLevel. PRIVATE)
+@AllArgsConstructor
+@ToString
+@EqualsAndHashCode
 public class Execution implements DeletedInterface, TenantInterface {
     @With
     @Hidden
diff --git a/core/src/main/java/io/kestra/plugin/core/trigger/Flow.java b/core/src/main/java/io/kestra/plugin/core/trigger/Flow.java
index df39f8021..82895281e 100644
--- a/core/src/main/java/io/kestra/plugin/core/trigger/Flow.java
+++ b/core/src/main/java/io/kestra/plugin/core/trigger/Flow.java
@@ -1,8 +1,12 @@
 package io.kestra.plugin.core.trigger;
 
+import io.kestra.core.exceptions.IllegalVariableEvaluationException;
+import io.kestra.core.models.Label;
 import io.kestra.core.models.annotations.PluginProperty;
 import io.kestra.core.models.property.Property;
 import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
 import lombok.*;
 import lombok.experimental.SuperBuilder;
 import io.kestra.core.models.annotations.Example;
@@ -112,7 +116,7 @@ public class Flow extends AbstractTrigger implements TriggerOutput<Flow.Output>
             .namespace(flow.getNamespace())
             .flowId(flow.getId())
             .flowRevision(flow.getRevision())
-            .labels(flow.getLabels())
+            .labels(generateLabels(runContext, flow))
             .state(new State())
             .trigger(ExecutionTrigger.of(
                 this,
@@ -149,6 +153,34 @@ public class Flow extends AbstractTrigger implements TriggerOutput<Flow.Output>
         }
     }
 
+    private List<Label> generateLabels(RunContext runContext, io.kestra.core.models.flows.Flow flow) {
+        final List<Label> labels = new ArrayList<>();
+
+        if (flow.getLabels() != null) {
+            labels.addAll(flow.getLabels()); // no need for rendering
+        }
+
+        if (this.getLabels() != null) {
+            for (Label label : this.getLabels()) {
+                final var value = renderLabelValue(runContext, label);
+                if (value != null) {
+                    labels.add(new Label(label.key(), value));
+                }
+            }
+        }
+
+        return labels;
+    }
+
+    private String renderLabelValue(RunContext runContext, Label label) {
+        try {
+            return runContext.render(label.value());
+        } catch (IllegalVariableEvaluationException e) {
+            runContext.logger().warn("Failed to render label '{}', it will be omitted", label.key(), e);
+            return null;
+        }
+    }
+
     @Builder
     @ToString
     @EqualsAndHashCode
diff --git a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
index 30385ebac..cb125ef39 100644
--- a/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
+++ b/core/src/main/java/io/kestra/plugin/core/trigger/Schedule.java
@@ -376,14 +376,13 @@ public class Schedule extends AbstractTrigger implements Schedulable, TriggerOut
                 // validate schedule condition can fail to render variables
                 // in this case, we return a failed execution so the trigger is not evaluated each second
                 runContext.logger().error("Unable to evaluate the Schedule trigger '{}'", this.getId(), ie);
-                List<Label> labels = generateLabels(conditionContext, backfill);
                 Execution execution = Execution.builder()
                     .id(runContext.getTriggerExecutionId())
                     .tenantId(triggerContext.getTenantId())
                     .namespace(triggerContext.getNamespace())
                     .flowId(triggerContext.getFlowId())
                     .flowRevision(conditionContext.getFlow().getRevision())
-                    .labels(labels)
+                    .labels(generateLabels(runContext, conditionContext, backfill))
                     .state(new State().withState(State.Type.FAILED))
                     .build();
                 return Optional.of(execution);
@@ -404,7 +403,7 @@ public class Schedule extends AbstractTrigger implements Schedulable, TriggerOut
             this,
             conditionContext,
             triggerContext,
-            generateLabels(conditionContext, backfill),
+            generateLabels(runContext, conditionContext, backfill),
             generateInputs(runContext, backfill),
             variables,
             Optional.empty()
@@ -425,19 +424,29 @@ public class Schedule extends AbstractTrigger implements Schedulable, TriggerOut
         return parser.parse(this.cron);
     }
 
-    private List<Label> generateLabels(ConditionContext conditionContext, Backfill backfill) {
+    private List<Label> generateLabels(RunContext runContext, ConditionContext conditionContext, Backfill backfill) throws IllegalVariableEvaluationException {
         List<Label> labels = new ArrayList<>();
 
         if (conditionContext.getFlow().getLabels() != null) {
-            labels.addAll(conditionContext.getFlow().getLabels());
+            labels.addAll(conditionContext.getFlow().getLabels()); // no need for rendering
         }
 
         if (backfill != null && backfill.getLabels() != null) {
-            labels.addAll(backfill.getLabels());
+            for (Label label : backfill.getLabels()) {
+                final var value = runContext.render(label.value());
+                if (value != null) {
+                    labels.add(new Label(label.key(), value));
+                }
+            }
         }
 
         if (this.getLabels() != null) {
-            labels.addAll(this.getLabels());
+            for (Label label : this.getLabels()) {
+                final var value = runContext.render(label.value());
+                if (value != null) {
+                    labels.add(new Label(label.key(), value));
+                }
+            }
         }
 
         return labels;
diff --git a/core/src/main/java/io/kestra/plugin/core/trigger/ScheduleOnDates.java b/core/src/main/java/io/kestra/plugin/core/trigger/ScheduleOnDates.java
index 28c131686..46ae73d66 100644
--- a/core/src/main/java/io/kestra/plugin/core/trigger/ScheduleOnDates.java
+++ b/core/src/main/java/io/kestra/plugin/core/trigger/ScheduleOnDates.java
@@ -83,7 +83,7 @@ public class ScheduleOnDates extends AbstractTrigger implements Schedulable, Tri
                 this,
                 conditionContext,
                 triggerContext,
-                generateLabels(conditionContext),
+                generateLabels(runContext, conditionContext),
                 this.inputs != null ? runContext.render(this.inputs) : Collections.emptyMap(),
                 Collections.emptyMap(),
                 nextDate
@@ -133,15 +133,20 @@ public class ScheduleOnDates extends AbstractTrigger implements Schedulable, Tri
             .map(date -> date.truncatedTo(ChronoUnit.SECONDS));
     }
 
-    private List<Label> generateLabels(ConditionContext conditionContext) {
+    private List<Label> generateLabels(RunContext runContext, ConditionContext conditionContext) throws IllegalVariableEvaluationException {
         List<Label> labels = new ArrayList<>();
 
         if (conditionContext.getFlow().getLabels() != null) {
-            labels.addAll(conditionContext.getFlow().getLabels());
+            labels.addAll(conditionContext.getFlow().getLabels()); // no need for rendering
         }
 
         if (this.getLabels() != null) {
-            labels.addAll(this.getLabels());
+            for (Label label : this.getLabels()) {
+                final var value = runContext.render(label.value());
+                if (value != null) {
+                    labels.add(new Label(label.key(), value));
+                }
+            }
         }
 
         return labels;
diff --git a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
index e4be74cab..27ba182ec 100644
--- a/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
+++ b/webserver/src/main/java/io/kestra/webserver/controllers/api/ExecutionController.java
@@ -6,10 +6,7 @@ import io.kestra.core.events.CrudEventType;
 import io.kestra.core.exceptions.IllegalVariableEvaluationException;
 import io.kestra.core.exceptions.InternalException;
 import io.kestra.core.models.Label;
-import io.kestra.core.models.executions.Execution;
-import io.kestra.core.models.executions.ExecutionKilled;
-import io.kestra.core.models.executions.ExecutionKilledExecution;
-import io.kestra.core.models.executions.TaskRun;
+import io.kestra.core.models.executions.*;
 import io.kestra.core.models.flows.Flow;
 import io.kestra.core.models.flows.FlowForExecution;
 import io.kestra.core.models.flows.FlowScope;
@@ -83,8 +80,7 @@ import jakarta.inject.Inject;
 import jakarta.inject.Named;
 import jakarta.validation.Valid;
 import jakarta.validation.constraints.NotNull;
-import lombok.Getter;
-import lombok.NoArgsConstructor;
+import lombok.*;
 import lombok.experimental.SuperBuilder;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.io.FilenameUtils;
@@ -103,6 +99,7 @@ import java.nio.charset.Charset;
 import java.nio.charset.IllegalCharsetNameException;
 import java.nio.charset.UnsupportedCharsetException;
 import java.time.Duration;
+import java.time.Instant;
 import java.time.ZonedDateTime;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -176,6 +173,9 @@ public class ExecutionController {
     @Inject
     private TenantService tenantService;
 
+    @Value("${kestra.url}")
+    private Optional<String> kestraUrl;
+
     @ExecuteOn(TaskExecutors.IO)
     @Get(uri = "/search")
     @Operation(tags = {"Executions"}, summary = "Search for executions")
@@ -561,7 +561,7 @@ public class ExecutionController {
     @ApiResponse(responseCode = "409", description = "if the flow is disabled")
     @SingleResult
     @Deprecated
-    public Publisher<Execution> trigger(
+    public Publisher<ExecutionResponse> trigger(
         @Parameter(description = "The flow namespace") @PathVariable String namespace,
         @Parameter(description = "The flow id") @Nullable @PathVariable String id,
         @Parameter(description = "The inputs") @Nullable  @Body MultipartBody inputs,
@@ -609,7 +609,7 @@ public class ExecutionController {
     @Operation(tags = {"Executions"}, summary = "Create a new execution for a flow")
     @ApiResponse(responseCode = "409", description = "if the flow is disabled")
     @SingleResult
-    public Publisher<Execution> create(
+    public Publisher<ExecutionResponse> create(
         @Parameter(description = "The flow namespace") @PathVariable String namespace,
         @Parameter(description = "The flow id") @PathVariable String id,
         @Parameter(description = "The inputs") @Nullable @Body MultipartBody inputs,
@@ -641,7 +641,7 @@ public class ExecutionController {
                     eventPublisher.publishEvent(new CrudEvent<>(current, CrudEventType.CREATE));
 
                     if (!wait) {
-                        sink.success(current);
+                        sink.success(ExecutionResponse.fromExecution(current, executionUrl(current)));
                     } else {
                         Runnable receive = this.executionQueue.receive(either -> {
                             if (either.isRight()) {
@@ -651,7 +651,7 @@ public class ExecutionController {
 
                             Execution item = either.getLeft();
                             if (item.getId().equals(current.getId()) && this.isStopFollow(flow, item)) {
-                                sink.success(item);
+                                sink.success(ExecutionResponse.fromExecution(item, executionUrl(item)));
                             }
                         });
 
@@ -663,6 +663,50 @@ public class ExecutionController {
             });
     }
 
+    private URI executionUrl(Execution execution) {
+        return URI.create(kestraUrl.orElse("") + "/ui" + (execution.getTenantId() != null ? "/" + execution.getTenantId(): "")
+            + "/executions/"
+            + execution.getNamespace() + "/"
+            + execution.getFlowId() + "/"
+            + execution.getId()
+        );
+    }
+
+    @Getter
+    public static class ExecutionResponse extends Execution {
+        private final URI url;
+
+        // This is not nice, but we cannot use @AllArgsConstructor as it would open a bunch of necessary changes on the Execution class.
+        ExecutionResponse(String tenantId, String id, String namespace, String flowId, Integer flowRevision, List<TaskRun> taskRunList, Map<String, Object> inputs, Map<String, Object> outputs, List<Label> labels, Map<String, Object> variables, State state, String parentId, String originalId, ExecutionTrigger trigger, boolean deleted, ExecutionMetadata metadata, Instant scheduleDate, URI url) {
+            super(tenantId, id, namespace, flowId, flowRevision, taskRunList, inputs, outputs, labels, variables, state, parentId, originalId, trigger, deleted, metadata, scheduleDate);
+
+            this.url = url;
+        }
+
+        public static ExecutionResponse fromExecution(Execution execution, URI url) {
+            return new ExecutionResponse(
+                execution.getTenantId(),
+                execution.getId(),
+                execution.getNamespace(),
+                execution.getFlowId(),
+                execution.getFlowRevision(),
+                execution.getTaskRunList(),
+                execution.getInputs(),
+                execution.outputs(),
+                execution.getLabels(),
+                execution.getVariables(),
+                execution.getState(),
+                execution.getParentId(),
+                execution.getOriginalId(),
+                execution.getTrigger(),
+                execution.isDeleted(),
+                execution.getMetadata(),
+                execution.getScheduleDate(),
+                url
+            );
+        }
+    }
+
     private List<Label> parseLabels(List<String> labels) {
         return labels == null ? null : RequestUtils.toMap(labels).entrySet().stream()
             .map(entry -> new Label(entry.getKey(), entry.getValue()))


Test patch:
diff --git a/core/src/test/java/io/kestra/plugin/core/trigger/FlowTest.java b/core/src/test/java/io/kestra/plugin/core/trigger/FlowTest.java
index 37abc2f83..d0f2d3172 100644
--- a/core/src/test/java/io/kestra/plugin/core/trigger/FlowTest.java
+++ b/core/src/test/java/io/kestra/plugin/core/trigger/FlowTest.java
@@ -16,6 +16,7 @@ import java.util.Optional;
 
 import static org.hamcrest.MatcherAssert.assertThat;
 import static org.hamcrest.Matchers.hasItem;
+import static org.hamcrest.Matchers.hasSize;
 import static org.hamcrest.Matchers.is;
 
 @KestraTest
@@ -109,4 +110,49 @@ class FlowTest {
         assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-1", "flow-label-1")));
         assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-2", "flow-label-2")));
     }
+
+    @Test
+    void success_withLabels() {
+        var flow = io.kestra.core.models.flows.Flow.builder()
+            .id("flow-with-flow-trigger")
+            .namespace("io.kestra.unittest")
+            .revision(1)
+            .labels(List.of(
+                new Label("flow-label-1", "flow-label-1"),
+                new Label("flow-label-2", "flow-label-2")
+            ))
+            .tasks(Collections.singletonList(Return.builder()
+                .id("test")
+                .type(Return.class.getName())
+                .format("test")
+                .build()))
+            .build();
+        var execution = Execution.builder()
+            .id(IdUtils.create())
+            .namespace("io.kestra.unittest")
+            .flowId("flow-with-flow-trigger")
+            .flowRevision(1)
+            .state(State.of(State.Type.RUNNING, Collections.emptyList()))
+            .build();
+        var flowTrigger = Flow.builder()
+            .id("flow")
+            .type(Flow.class.getName())
+            .labels(List.of(
+                new Label("trigger-label-1", "trigger-label-1"),
+                new Label("trigger-label-2", "{{ 'trigger-label-2' }}"),
+                new Label("trigger-label-3", "{{ null }}"), // should return an empty string
+                new Label("trigger-label-4", "{{ foobar }}") // should fail
+            ))
+            .build();
+
+        Optional<Execution> evaluate = flowTrigger.evaluate(runContextFactory.of(), flow, execution);
+
+        assertThat(evaluate.isPresent(), is(true));
+        assertThat(evaluate.get().getLabels(), hasSize(5));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-1", "flow-label-1")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("flow-label-2", "flow-label-2")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-1", "trigger-label-1")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-2", "trigger-label-2")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-3", "")));
+    }
 }
diff --git a/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java b/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
index a80c468aa..c2d28414e 100644
--- a/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
+++ b/core/src/test/java/io/kestra/plugin/core/trigger/ScheduleTest.java
@@ -135,6 +135,35 @@ class ScheduleTest {
         assertThat(inputs.get("input2"), is("default"));
     }
 
+    @Test
+    void success_withLabels() throws Exception {
+        var scheduleTrigger = Schedule.builder()
+            .id("schedule")
+            .cron("0 0 1 * *")
+            .labels(List.of(
+                new Label("trigger-label-1", "trigger-label-1"),
+                new Label("trigger-label-2", "{{ 'trigger-label-2' }}"),
+                new Label("trigger-label-3", "{{ null }}")
+            ))
+            .build();
+        var conditionContext = conditionContext(scheduleTrigger);
+        var date = ZonedDateTime.now()
+            .withDayOfMonth(1)
+            .withHour(0)
+            .withMinute(0)
+            .withSecond(0)
+            .truncatedTo(ChronoUnit.SECONDS)
+            .minusMonths(1);
+        var triggerContext = triggerContext(date, scheduleTrigger);
+
+        Optional<Execution> evaluate = scheduleTrigger.evaluate(conditionContext, triggerContext);
+
+        assertThat(evaluate.isPresent(), is(true));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-1", "trigger-label-1")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-2", "trigger-label-2")));
+        assertThat(evaluate.get().getLabels(), hasItem(new Label("trigger-label-3", "")));
+    }
+
     @SuppressWarnings("unchecked")
     @Test
     void everyMinute() throws Exception {
diff --git a/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java b/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java
index d7c89fe3f..86926d2a1 100644
--- a/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java
+++ b/webserver/src/test/java/io/kestra/webserver/controllers/api/ExecutionControllerTest.java
@@ -46,6 +46,7 @@ import org.junitpioneer.jupiter.RetryingTest;
 import reactor.core.publisher.Flux;
 
 import java.io.File;
+import java.net.URI;
 import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.time.Duration;
@@ -1515,4 +1516,19 @@ class ExecutionControllerTest extends JdbcH2ControllerTest {
         Assertions.assertFalse(response.inputs().isEmpty());
         Assertions.assertTrue(response.inputs().stream().allMatch(ExecutionController.ApiValidateExecutionInputsResponse.ApiInputAndValue::enabled));
     }
+
+    @Test
+   void shouldHaveAnUrlWhenCreated() {
+        // ExecutionController.ExecutionResponse cannot be deserialized because it didn't have any default constructor.
+        // adding it would mean updating the Execution itself, which is too annoying, so for the test we just deserialize to a Map.
+        Map<?, ?> executionResult = client.toBlocking().retrieve(
+            HttpRequest
+                .POST("/api/v1/executions/" + TESTS_FLOW_NS + "/minimal", null)
+                .contentType(MediaType.MULTIPART_FORM_DATA_TYPE),
+            Map.class
+        );
+
+        assertThat(executionResult, notNullValue());
+        assertThat(executionResult.get("url"), is("http://localhost:8081/ui/executions/io.kestra.tests/minimal/" + executionResult.get("id")));
+    }
 }
diff --git a/webserver/src/test/resources/application-test.yml b/webserver/src/test/resources/application-test.yml
index 348f90d2c..e7e58a906 100644
--- a/webserver/src/test/resources/application-test.yml
+++ b/webserver/src/test/resources/application-test.yml
@@ -30,6 +30,7 @@ jackson:
     FAIL_ON_UNKNOWN_PROPERTIES: false
 
 kestra:
+  url: http://localhost:8081
   encryption:
     secret-key: I6EGNzRESu3X3pKZidrqCGOHQFUFC0yK
   server-type: STANDALONE