mirror of
https://github.com/kestra-io/kestra.git
synced 2025-12-23 21:04:39 -05:00
feat(flows): add new check conditions
Adds new property 'checks' on flow in order to allow pre-conditions to be evaluated before execution Fixes: kestra-io/kestra-ee#5759
This commit is contained in:
committed by
Florian Hussonnois
parent
90ee720d49
commit
eb51c5be37
@@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
|
|||||||
import io.kestra.core.exceptions.InternalException;
|
import io.kestra.core.exceptions.InternalException;
|
||||||
import io.kestra.core.models.HasUID;
|
import io.kestra.core.models.HasUID;
|
||||||
import io.kestra.core.models.annotations.PluginProperty;
|
import io.kestra.core.models.annotations.PluginProperty;
|
||||||
|
import io.kestra.core.models.flows.check.Check;
|
||||||
import io.kestra.core.models.flows.sla.SLA;
|
import io.kestra.core.models.flows.sla.SLA;
|
||||||
import io.kestra.core.models.listeners.Listener;
|
import io.kestra.core.models.listeners.Listener;
|
||||||
import io.kestra.core.models.tasks.FlowableTask;
|
import io.kestra.core.models.tasks.FlowableTask;
|
||||||
@@ -129,6 +130,14 @@ public class Flow extends AbstractFlow implements HasUID {
|
|||||||
@Valid
|
@Valid
|
||||||
@PluginProperty
|
@PluginProperty
|
||||||
List<SLA> sla;
|
List<SLA> sla;
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
title = "Conditions evaluated before the flow is executed.",
|
||||||
|
description = "A list of conditions that are evaluated before the flow is executed. If no checks are defined, the flow executes normally."
|
||||||
|
)
|
||||||
|
@Valid
|
||||||
|
@PluginProperty
|
||||||
|
List<Check> checks;
|
||||||
|
|
||||||
public Stream<String> allTypes() {
|
public Stream<String> allTypes() {
|
||||||
return Stream.of(
|
return Stream.of(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public class FlowWithSource extends Flow {
|
|||||||
.concurrency(this.concurrency)
|
.concurrency(this.concurrency)
|
||||||
.retry(this.retry)
|
.retry(this.retry)
|
||||||
.sla(this.sla)
|
.sla(this.sla)
|
||||||
|
.checks(this.checks)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ public class FlowWithSource extends Flow {
|
|||||||
.concurrency(flow.concurrency)
|
.concurrency(flow.concurrency)
|
||||||
.retry(flow.retry)
|
.retry(flow.retry)
|
||||||
.sla(flow.sla)
|
.sla(flow.sla)
|
||||||
|
.checks(flow.checks)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
109
core/src/main/java/io/kestra/core/models/flows/check/Check.java
Normal file
109
core/src/main/java/io/kestra/core/models/flows/check/Check.java
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
package io.kestra.core.models.flows.check;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.experimental.SuperBuilder;
|
||||||
|
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a check within a Kestra flow.
|
||||||
|
* <p>
|
||||||
|
* A {@code Check} defines a boolean condition that is evaluated when validating flow's inputs
|
||||||
|
* and before triggering an execution.
|
||||||
|
* <p>
|
||||||
|
* If the condition evaluates to {@code false}, the configured {@link Behavior}
|
||||||
|
* determines how the execution proceeds, and the {@link Style} determines how
|
||||||
|
* the message is visually presented in the UI.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
@SuperBuilder
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Check {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The condition to evaluate.
|
||||||
|
*/
|
||||||
|
@NotNull
|
||||||
|
@NotEmpty
|
||||||
|
String condition;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message associated with this check, will be displayed when the condition evaluates to {@code false}.
|
||||||
|
*/
|
||||||
|
@NotEmpty
|
||||||
|
String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the style of the message displayed in the UI when the condition evaluates to {@code false}.
|
||||||
|
*/
|
||||||
|
Style style = Style.INFO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The behavior to apply when the condition evaluates to {@code false}.
|
||||||
|
*/
|
||||||
|
Behavior behavior = Behavior.BLOCK_EXECUTION;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The visual style used to display the message when the check fails.
|
||||||
|
*/
|
||||||
|
public enum Style {
|
||||||
|
/**
|
||||||
|
* Display the message as an error.
|
||||||
|
*/
|
||||||
|
ERROR,
|
||||||
|
/**
|
||||||
|
* Display the message as a success indicator.
|
||||||
|
*/
|
||||||
|
SUCCESS,
|
||||||
|
/**
|
||||||
|
* Display the message as a warning.
|
||||||
|
*/
|
||||||
|
WARNING,
|
||||||
|
/**
|
||||||
|
* Display the message as informational content.
|
||||||
|
*/
|
||||||
|
INFO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines how the flow should behave when the condition evaluates to {@code false}.
|
||||||
|
*/
|
||||||
|
public enum Behavior {
|
||||||
|
/**
|
||||||
|
* Block the creation of the execution.
|
||||||
|
*/
|
||||||
|
BLOCK_EXECUTION,
|
||||||
|
/**
|
||||||
|
* Create the execution as failed.
|
||||||
|
*/
|
||||||
|
FAIL_EXECUTION,
|
||||||
|
/**
|
||||||
|
* Create a new execution as a result of the check failing.
|
||||||
|
*/
|
||||||
|
CREATE_EXECUTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the effective behavior for a list of {@link Check}s based on priority.
|
||||||
|
*
|
||||||
|
* @param checks the list of checks whose behaviors are to be evaluated
|
||||||
|
* @return the highest-priority behavior, or {@code CREATE_EXECUTION} if the list is empty or only contains nulls
|
||||||
|
*/
|
||||||
|
public static Check.Behavior resolveBehavior(List<Check> checks) {
|
||||||
|
if (checks == null || checks.isEmpty()) {
|
||||||
|
return Behavior.CREATE_EXECUTION;
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks.stream()
|
||||||
|
.map(Check::getBehavior)
|
||||||
|
.filter(Objects::nonNull).min(Comparator.comparingInt(Enum::ordinal))
|
||||||
|
.orElse(Behavior.CREATE_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -2,20 +2,26 @@ package io.kestra.core.services;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||||
import io.kestra.core.exceptions.FlowProcessingException;
|
import io.kestra.core.exceptions.FlowProcessingException;
|
||||||
|
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
|
||||||
import io.kestra.core.models.executions.Execution;
|
import io.kestra.core.models.executions.Execution;
|
||||||
import io.kestra.core.models.flows.*;
|
import io.kestra.core.models.flows.*;
|
||||||
|
import io.kestra.core.models.flows.check.Check;
|
||||||
import io.kestra.core.models.tasks.RunnableTask;
|
import io.kestra.core.models.tasks.RunnableTask;
|
||||||
import io.kestra.core.models.topologies.FlowTopology;
|
import io.kestra.core.models.topologies.FlowTopology;
|
||||||
import io.kestra.core.models.triggers.AbstractTrigger;
|
import io.kestra.core.models.triggers.AbstractTrigger;
|
||||||
|
import io.kestra.core.models.validations.ManualConstraintViolation;
|
||||||
import io.kestra.core.models.validations.ModelValidator;
|
import io.kestra.core.models.validations.ModelValidator;
|
||||||
import io.kestra.core.models.validations.ValidateConstraintViolation;
|
import io.kestra.core.models.validations.ValidateConstraintViolation;
|
||||||
import io.kestra.core.plugins.PluginRegistry;
|
import io.kestra.core.plugins.PluginRegistry;
|
||||||
import io.kestra.core.repositories.FlowRepositoryInterface;
|
import io.kestra.core.repositories.FlowRepositoryInterface;
|
||||||
import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
|
import io.kestra.core.repositories.FlowTopologyRepositoryInterface;
|
||||||
|
import io.kestra.core.runners.RunContext;
|
||||||
|
import io.kestra.core.runners.RunContextFactory;
|
||||||
import io.kestra.core.serializers.JacksonMapper;
|
import io.kestra.core.serializers.JacksonMapper;
|
||||||
import io.kestra.core.utils.ListUtils;
|
import io.kestra.core.utils.ListUtils;
|
||||||
import io.kestra.plugin.core.flow.Pause;
|
import io.kestra.plugin.core.flow.Pause;
|
||||||
import jakarta.inject.Inject;
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.inject.Provider;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import jakarta.validation.ConstraintViolationException;
|
import jakarta.validation.ConstraintViolationException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@@ -27,6 +33,7 @@ import java.lang.reflect.Method;
|
|||||||
import java.lang.reflect.Modifier;
|
import java.lang.reflect.Modifier;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Supplier;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.IntStream;
|
import java.util.stream.IntStream;
|
||||||
@@ -53,6 +60,9 @@ public class FlowService {
|
|||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
Optional<FlowTopologyRepositoryInterface> flowTopologyRepository;
|
Optional<FlowTopologyRepositoryInterface> flowTopologyRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
Provider<RunContextFactory> runContextFactory; // Lazy init: avoid circular dependency error.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates and creates the given flow.
|
* Validates and creates the given flow.
|
||||||
@@ -84,7 +94,51 @@ public class FlowService {
|
|||||||
return flowRepository
|
return flowRepository
|
||||||
.orElseThrow(() -> new IllegalStateException("Cannot perform operation on flow. Cause: No FlowRepository"));
|
.orElseThrow(() -> new IllegalStateException("Cannot perform operation on flow. Cause: No FlowRepository"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluates all checks defined in the given flow using the provided inputs.
|
||||||
|
* <p>
|
||||||
|
* Each check's {@link Check#getCondition()} is evaluated in the context of the flow.
|
||||||
|
* If a condition evaluates to {@code false} or fails to evaluate due to a
|
||||||
|
* variable error, the corresponding {@link Check} is added to the returned list.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param flow the flow containing the checks to evaluate
|
||||||
|
* @param inputs the input values used when evaluating the conditions
|
||||||
|
* @return a list of checks whose conditions evaluated to {@code false} or failed to evaluate
|
||||||
|
*/
|
||||||
|
public List<Check> getFailedChecks(Flow flow, Map<String, Object> inputs) {
|
||||||
|
if (!ListUtils.isEmpty(flow.getChecks())) {
|
||||||
|
RunContext runContext = runContextFactory.get().of(flow, Map.of("inputs", inputs));
|
||||||
|
List<Check> falseConditions = new ArrayList<>();
|
||||||
|
for (Check check : flow.getChecks()) {
|
||||||
|
try {
|
||||||
|
boolean result = Boolean.TRUE.equals(runContext.renderTyped(check.getCondition()));
|
||||||
|
if (!result) {
|
||||||
|
falseConditions.add(check);
|
||||||
|
}
|
||||||
|
} catch (IllegalVariableEvaluationException e) {
|
||||||
|
log.debug("[tenant: {}] [namespace: {}] [flow: {}] Failed to evaluate check condition. Cause.: {}",
|
||||||
|
flow.getTenantId(),
|
||||||
|
flow.getNamespace(),
|
||||||
|
flow.getId(),
|
||||||
|
e.getMessage(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
falseConditions.add(Check
|
||||||
|
.builder()
|
||||||
|
.message("Failed to evaluate check condition. Cause: " + e.getMessage())
|
||||||
|
.behavior(Check.Behavior.BLOCK_EXECUTION)
|
||||||
|
.style(Check.Style.ERROR)
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return falseConditions;
|
||||||
|
}
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates the given flow source.
|
* Validates the given flow source.
|
||||||
* <p>
|
* <p>
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package io.kestra.core.models.flows.check;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.AssertionsForClassTypes.assertThat;
|
||||||
|
|
||||||
|
class CheckTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCreateExecutionGivenNullList() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = null;
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.CREATE_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCreateExecutionGivenEmptyList() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = List.of();
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.CREATE_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCreateExecutionGivenOnlyCreateExecutionChecks() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = List.of(
|
||||||
|
Check.builder().behavior(Check.Behavior.CREATE_EXECUTION).build(),
|
||||||
|
Check.builder().behavior(Check.Behavior.CREATE_EXECUTION).build()
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.CREATE_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFailExecutionGivenFailAndCreateChecks() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = List.of(
|
||||||
|
Check.builder().behavior(Check.Behavior.CREATE_EXECUTION).build(),
|
||||||
|
Check.builder().behavior(Check.Behavior.FAIL_EXECUTION).build()
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.FAIL_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnBlockExecutionGivenMixedBehaviors() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = List.of(
|
||||||
|
Check.builder().behavior(Check.Behavior.CREATE_EXECUTION).build(),
|
||||||
|
Check.builder().behavior(Check.Behavior.FAIL_EXECUTION).build(),
|
||||||
|
Check.builder().behavior(Check.Behavior.BLOCK_EXECUTION).build()
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.BLOCK_EXECUTION);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldIgnoreNullBehaviorsGivenMixedValues() {
|
||||||
|
// Given
|
||||||
|
List<Check> checks = List.of(
|
||||||
|
Check.builder().behavior(null).build(),
|
||||||
|
Check.builder().behavior(Check.Behavior.CREATE_EXECUTION).build()
|
||||||
|
);
|
||||||
|
|
||||||
|
// When
|
||||||
|
Check.Behavior result = Check.resolveBehavior(checks);
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEqualTo(Check.Behavior.CREATE_EXECUTION);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
package io.kestra.core.services;
|
package io.kestra.core.services;
|
||||||
|
|
||||||
import io.kestra.core.exceptions.FlowProcessingException;
|
import io.kestra.core.exceptions.FlowProcessingException;
|
||||||
|
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
|
||||||
import io.kestra.core.junit.annotations.KestraTest;
|
import io.kestra.core.junit.annotations.KestraTest;
|
||||||
|
import io.kestra.core.models.flows.Flow;
|
||||||
import io.kestra.core.models.flows.FlowInterface;
|
import io.kestra.core.models.flows.FlowInterface;
|
||||||
import io.kestra.core.models.flows.FlowWithSource;
|
import io.kestra.core.models.flows.FlowWithSource;
|
||||||
import io.kestra.core.models.flows.GenericFlow;
|
import io.kestra.core.models.flows.GenericFlow;
|
||||||
import io.kestra.core.models.flows.Type;
|
import io.kestra.core.models.flows.Type;
|
||||||
|
import io.kestra.core.models.flows.check.Check;
|
||||||
import io.kestra.core.models.flows.input.StringInput;
|
import io.kestra.core.models.flows.input.StringInput;
|
||||||
import io.kestra.core.models.property.Property;
|
import io.kestra.core.models.property.Property;
|
||||||
import io.kestra.core.models.validations.ValidateConstraintViolation;
|
import io.kestra.core.models.validations.ValidateConstraintViolation;
|
||||||
@@ -17,11 +20,14 @@ import org.junit.jupiter.api.Test;
|
|||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@KestraTest
|
@KestraTest
|
||||||
class FlowServiceTest {
|
class FlowServiceTest {
|
||||||
@@ -431,4 +437,106 @@ class FlowServiceTest {
|
|||||||
assertThat(results).hasSize(1);
|
assertThat(results).hasSize(1);
|
||||||
assertThat(results.getFirst().getConstraints()).contains("Flow id is a reserved keyword: pause");
|
assertThat(results.getFirst().getConstraints()).contains("Flow id is a reserved keyword: pause");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListGivenFlowWithNoChecks() {
|
||||||
|
// Given
|
||||||
|
Flow flow = mock(Flow.class);
|
||||||
|
when(flow.getChecks()).thenReturn(List.of());
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<Check> result = flowService.getFailedChecks(flow, Map.of());
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCheckWhenConditionEvaluatesFalse() {
|
||||||
|
// Given
|
||||||
|
Check failingCheck = Check.builder()
|
||||||
|
.condition("{{ false }}")
|
||||||
|
.message("fail")
|
||||||
|
.behavior(Check.Behavior.FAIL_EXECUTION)
|
||||||
|
.build();
|
||||||
|
Flow flow = mock(Flow.class);
|
||||||
|
when(flow.getChecks()).thenReturn(List.of(failingCheck));
|
||||||
|
when(flow.getNamespace()).thenReturn("io.kestra.unittest");
|
||||||
|
when(flow.getId()).thenReturn("test");
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<Check> result = flowService.getFailedChecks(flow, Map.of());
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
assertThat(result.getFirst()).isEqualTo(failingCheck);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyListWhenConditionEvaluatesTrue() {
|
||||||
|
// Given
|
||||||
|
Check passingCheck = Check.builder()
|
||||||
|
.condition("{{ true }}")
|
||||||
|
.message("pass")
|
||||||
|
.behavior(Check.Behavior.FAIL_EXECUTION)
|
||||||
|
.build();
|
||||||
|
Flow flow = mock(Flow.class);
|
||||||
|
when(flow.getChecks()).thenReturn(List.of(passingCheck));
|
||||||
|
when(flow.getNamespace()).thenReturn("io.kestra.unittest");
|
||||||
|
when(flow.getId()).thenReturn("test");
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<Check> result = flowService.getFailedChecks(flow, Map.of());
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnCheckWithErrorMessageWhenExceptionThrown() {
|
||||||
|
// Given
|
||||||
|
Check check = Check.builder()
|
||||||
|
.condition("{{ invalidFunction() }}")
|
||||||
|
.message("ignored")
|
||||||
|
.behavior(Check.Behavior.FAIL_EXECUTION)
|
||||||
|
.build();
|
||||||
|
Flow flow = mock(Flow.class);
|
||||||
|
when(flow.getChecks()).thenReturn(List.of(check));
|
||||||
|
when(flow.getNamespace()).thenReturn("io.kestra.unittest");
|
||||||
|
when(flow.getId()).thenReturn("test");
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<Check> result = flowService.getFailedChecks(flow, Map.of());
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).hasSize(1);
|
||||||
|
Check errorCheck = result.getFirst();
|
||||||
|
assertThat(errorCheck.getBehavior()).isEqualTo(Check.Behavior.BLOCK_EXECUTION);
|
||||||
|
assertThat(errorCheck.getStyle()).isEqualTo(Check.Style.ERROR);
|
||||||
|
assertThat(errorCheck.getMessage()).contains("Failed to evaluate check condition. Cause:");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHandleMultipleChecksWithMixedResults() {
|
||||||
|
// Given
|
||||||
|
Check passCheck = Check.builder().condition("{{ true }}").message("pass").build();
|
||||||
|
Check failCheck = Check.builder().condition("{{ false }}").message("fail").build();
|
||||||
|
Check exceptionCheck = Check.builder().condition("{{ invalidFunction }}").message("exception").build();
|
||||||
|
|
||||||
|
Flow flow = mock(Flow.class);
|
||||||
|
when(flow.getChecks()).thenReturn(List.of(passCheck, failCheck, exceptionCheck));
|
||||||
|
when(flow.getNamespace()).thenReturn("io.kestra.unittest");
|
||||||
|
when(flow.getId()).thenReturn("test");
|
||||||
|
|
||||||
|
// When
|
||||||
|
List<Check> result = flowService.getFailedChecks(flow, Map.of());
|
||||||
|
|
||||||
|
// Then
|
||||||
|
assertThat(result).hasSize(2);
|
||||||
|
assertThat(result).contains(failCheck);
|
||||||
|
assertThat(result)
|
||||||
|
.anyMatch(c -> c.getMessage().contains("Failed to evaluate check condition") &&
|
||||||
|
c.getBehavior() == Check.Behavior.BLOCK_EXECUTION &&
|
||||||
|
c.getStyle() == Check.Style.ERROR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,9 +4,22 @@
|
|||||||
<strong>{{ $t('disabled flow title') }}</strong><br>
|
<strong>{{ $t('disabled flow title') }}</strong><br>
|
||||||
{{ $t('disabled flow desc') }}
|
{{ $t('disabled flow desc') }}
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
<div class="flow-execution-checks-alerts">
|
||||||
|
<el-alert v-for="alert in checks || []" :type="alert.style.toLowerCase()" showIcon :closable="false" :key="alert">
|
||||||
|
{{ alert.message }}
|
||||||
|
</el-alert>
|
||||||
|
</div>
|
||||||
<el-form labelPosition="top" :model="inputs" ref="form" @submit.prevent="false">
|
<el-form labelPosition="top" :model="inputs" ref="form" @submit.prevent="false">
|
||||||
<InputsForm :initialInputs="flow.inputs" :selectedTrigger="selectedTrigger" :flow="flow" v-model="inputs" :executeClicked="executeClicked" @confirm="onSubmit($refs.form)" @update:model-value-no-default="values => inputsNoDefaults=values" />
|
<InputsForm
|
||||||
|
:initialInputs="flow.inputs"
|
||||||
|
:selectedTrigger="selectedTrigger"
|
||||||
|
:flow="flow"
|
||||||
|
v-model="inputs"
|
||||||
|
:executeClicked="executeClicked"
|
||||||
|
@confirm="onSubmit($refs.form)"
|
||||||
|
@update:model-value-no-default="values => inputsNoDefaults=values"
|
||||||
|
@update:checks="values => checks=values"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-collapse v-model="collapseName">
|
<el-collapse v-model="collapseName">
|
||||||
<el-collapse-item :title="$t('advanced configuration')" name="advanced">
|
<el-collapse-item :title="$t('advanced configuration')" name="advanced">
|
||||||
@@ -48,7 +61,7 @@
|
|||||||
<el-button
|
<el-button
|
||||||
:data-test-id="buttonTestId"
|
:data-test-id="buttonTestId"
|
||||||
:icon="buttonIcon"
|
:icon="buttonIcon"
|
||||||
:disabled="!flowCanBeExecuted"
|
:disabled="!flowCanBeExecuted || hasBlockingChecks()"
|
||||||
:class="{'flow-run-trigger-button': true, 'onboarding-glow': coreStore.guidedProperties.tourStarted}"
|
:class="{'flow-run-trigger-button': true, 'onboarding-glow': coreStore.guidedProperties.tourStarted}"
|
||||||
type="primary"
|
type="primary"
|
||||||
nativeType="submit"
|
nativeType="submit"
|
||||||
@@ -113,6 +126,7 @@
|
|||||||
collapseName: undefined,
|
collapseName: undefined,
|
||||||
newTab: localStorage.getItem(storageKeys.EXECUTE_FLOW_BEHAVIOUR) === executeFlowBehaviours.NEW_TAB,
|
newTab: localStorage.getItem(storageKeys.EXECUTE_FLOW_BEHAVIOUR) === executeFlowBehaviours.NEW_TAB,
|
||||||
executeClicked: false,
|
executeClicked: false,
|
||||||
|
checks: []
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
emits: ["executionTrigger", "updateInputs", "updateLabels"],
|
emits: ["executionTrigger", "updateInputs", "updateLabels"],
|
||||||
@@ -141,6 +155,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
hasBlockingChecks() {
|
||||||
|
return this.checks.filter(check => check.behavior === "BLOCK_EXECUTION").length > 0;
|
||||||
|
},
|
||||||
getExecutionLabels() {
|
getExecutionLabels() {
|
||||||
if (!this.execution.labels) {
|
if (!this.execution.labels) {
|
||||||
return [];
|
return [];
|
||||||
@@ -243,6 +260,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
.flow-execution-checks-alerts {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
:deep(.el-collapse) {
|
:deep(.el-collapse) {
|
||||||
border-radius: var(--bs-border-radius-lg);
|
border-radius: var(--bs-border-radius-lg);
|
||||||
border: 1px solid var(--ks-border-primary);
|
border: 1px solid var(--ks-border-primary);
|
||||||
|
|||||||
@@ -341,10 +341,10 @@
|
|||||||
DeleteOutline,
|
DeleteOutline,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
ContentSave,
|
ContentSave
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
emits: ["update:modelValue", "update:modelValueNoDefault", "confirm", "validation"],
|
emits: ["update:modelValue", "update:modelValueNoDefault", "update:checks", "confirm", "validation"],
|
||||||
created() {
|
created() {
|
||||||
this.inputsMetaData = JSON.parse(JSON.stringify(this.initialInputs));
|
this.inputsMetaData = JSON.parse(JSON.stringify(this.initialInputs));
|
||||||
this.debouncedValidation = debounce(this.validateInputs, 500)
|
this.debouncedValidation = debounce(this.validateInputs, 500)
|
||||||
@@ -391,6 +391,7 @@
|
|||||||
document.removeEventListener("keydown", this._keyListener);
|
document.removeEventListener("keydown", this._keyListener);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
normalizeJSON(value) {
|
normalizeJSON(value) {
|
||||||
try {
|
try {
|
||||||
// Step 1: Remove trailing commas in objects and arrays
|
// Step 1: Remove trailing commas in objects and arrays
|
||||||
@@ -537,6 +538,7 @@
|
|||||||
const formData = inputsToFormData(this, this.inputsMetaData, inputsValuesWithNoDefault);
|
const formData = inputsToFormData(this, this.inputsMetaData, inputsValuesWithNoDefault);
|
||||||
|
|
||||||
const metadataCallback = (response) => {
|
const metadataCallback = (response) => {
|
||||||
|
this.$emit("update:checks", response.checks || []);
|
||||||
this.inputsMetaData = response.inputs.reduce((acc,it) => {
|
this.inputsMetaData = response.inputs.reduce((acc,it) => {
|
||||||
if(it.enabled){
|
if(it.enabled){
|
||||||
acc.push({...it.input, errors: it.errors, value: it.value || it.input.prefill, isDefault: it.isDefault});
|
acc.push({...it.input, errors: it.errors, value: it.value || it.input.prefill, isDefault: it.isDefault});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import io.kestra.core.models.Label;
|
|||||||
import io.kestra.core.models.QueryFilter;
|
import io.kestra.core.models.QueryFilter;
|
||||||
import io.kestra.core.models.executions.*;
|
import io.kestra.core.models.executions.*;
|
||||||
import io.kestra.core.models.flows.*;
|
import io.kestra.core.models.flows.*;
|
||||||
|
import io.kestra.core.models.flows.check.Check;
|
||||||
import io.kestra.core.models.flows.input.InputAndValue;
|
import io.kestra.core.models.flows.input.InputAndValue;
|
||||||
import io.kestra.core.models.hierarchies.FlowGraph;
|
import io.kestra.core.models.hierarchies.FlowGraph;
|
||||||
import io.kestra.core.models.storage.FileMetas;
|
import io.kestra.core.models.storage.FileMetas;
|
||||||
@@ -91,6 +92,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.apache.commons.io.FilenameUtils;
|
import org.apache.commons.io.FilenameUtils;
|
||||||
import org.apache.commons.lang3.exception.ExceptionUtils;
|
import org.apache.commons.lang3.exception.ExceptionUtils;
|
||||||
import org.reactivestreams.Publisher;
|
import org.reactivestreams.Publisher;
|
||||||
|
import org.slf4j.event.Level;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.FluxSink;
|
import reactor.core.publisher.FluxSink;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -190,14 +192,14 @@ public class ExecutionController {
|
|||||||
@Inject
|
@Inject
|
||||||
private Optional<OpenTelemetry> openTelemetry;
|
private Optional<OpenTelemetry> openTelemetry;
|
||||||
|
|
||||||
@Inject
|
|
||||||
private ExecutionStreamingService executionStreamingService;
|
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private LocalPathFactory localPathFactory;
|
private LocalPathFactory localPathFactory;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
private SecureVariableRendererFactory secureVariableRendererFactory;
|
private SecureVariableRendererFactory secureVariableRendererFactory;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
private LogService logService;
|
||||||
|
|
||||||
@Value("${" + LocalPath.ENABLE_PREVIEW_CONFIG + ":true}")
|
@Value("${" + LocalPath.ENABLE_PREVIEW_CONFIG + ":true}")
|
||||||
private boolean enableLocalFilePreview;
|
private boolean enableLocalFilePreview;
|
||||||
@@ -691,7 +693,11 @@ public class ExecutionController {
|
|||||||
Execution execution = Execution.newExecution(flow, parsedLabels);
|
Execution execution = Execution.newExecution(flow, parsedLabels);
|
||||||
return flowInputOutput
|
return flowInputOutput
|
||||||
.validateExecutionInputs(flow.getInputs(), flow, execution, inputs)
|
.validateExecutionInputs(flow.getInputs(), flow, execution, inputs)
|
||||||
.map(values -> ApiValidateExecutionInputsResponse.of(id, namespace, values));
|
.map(values -> {
|
||||||
|
Map<String, Object> inputsAsMap = values.stream().collect(HashMap::new, (m,v)->m.put(v.input().getId(), v.value()), HashMap::putAll);
|
||||||
|
List<Check> checks = flowService.getFailedChecks(flow, inputsAsMap);
|
||||||
|
return ApiValidateExecutionInputsResponse.of(id, namespace, checks, values);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExecuteOn(TaskExecutors.IO)
|
@ExecuteOn(TaskExecutors.IO)
|
||||||
@@ -729,7 +735,24 @@ public class ExecutionController {
|
|||||||
|
|
||||||
return flowInputOutput.readExecutionInputs(flow, current, inputs)
|
return flowInputOutput.readExecutionInputs(flow, current, inputs)
|
||||||
.flatMap(executionInputs -> {
|
.flatMap(executionInputs -> {
|
||||||
Execution executionWithInputs = current.withInputs(executionInputs);
|
Check.Behavior behavior = Check.resolveBehavior(flowService.getFailedChecks(flow, executionInputs));
|
||||||
|
if (Check.Behavior.BLOCK_EXECUTION.equals(behavior)) {
|
||||||
|
return Mono.error(new IllegalArgumentException(
|
||||||
|
"Flow execution blocked: one or more condition checks evaluated to false."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final Execution executionWithInputs = Optional.of(current.withInputs(executionInputs))
|
||||||
|
.map(exec -> {
|
||||||
|
if (Check.Behavior.FAIL_EXECUTION.equals(behavior)) {
|
||||||
|
this.logService.logExecution(current, log, Level.WARN, "Flow execution failed because one or more condition checks evaluated to false.");
|
||||||
|
return exec.withState(State.Type.FAILED);
|
||||||
|
} else {
|
||||||
|
return exec;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.get();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// inject the traceparent into the execution
|
// inject the traceparent into the execution
|
||||||
openTelemetry
|
openTelemetry
|
||||||
@@ -740,7 +763,7 @@ public class ExecutionController {
|
|||||||
executionQueue.emit(executionWithInputs);
|
executionQueue.emit(executionWithInputs);
|
||||||
eventPublisher.publishEvent(new CrudEvent<>(executionWithInputs, CrudEventType.CREATE));
|
eventPublisher.publishEvent(new CrudEvent<>(executionWithInputs, CrudEventType.CREATE));
|
||||||
|
|
||||||
if (!wait) {
|
if (!wait || executionWithInputs.getState().isFailed()) {
|
||||||
return Mono.just(ExecutionResponse.fromExecution(
|
return Mono.just(ExecutionResponse.fromExecution(
|
||||||
executionWithInputs,
|
executionWithInputs,
|
||||||
executionUrl(executionWithInputs)
|
executionUrl(executionWithInputs)
|
||||||
@@ -1457,7 +1480,7 @@ public class ExecutionController {
|
|||||||
Flow flow = flowRepository.findByExecutionWithoutAcl(execution);
|
Flow flow = flowRepository.findByExecutionWithoutAcl(execution);
|
||||||
|
|
||||||
return executionService.validateForResume(execution, flow, inputs)
|
return executionService.validateForResume(execution, flow, inputs)
|
||||||
.map(values -> ApiValidateExecutionInputsResponse.of(execution.getFlowId(), execution.getNamespace(), values))
|
.map(values -> ApiValidateExecutionInputsResponse.of(execution.getFlowId(), execution.getNamespace(), List.of(), values))
|
||||||
// need to consume the inputs in case of error
|
// need to consume the inputs in case of error
|
||||||
.doOnError(t -> Flux.from(inputs).subscribeOn(Schedulers.boundedElastic()).blockLast());
|
.doOnError(t -> Flux.from(inputs).subscribeOn(Schedulers.boundedElastic()).blockLast());
|
||||||
}
|
}
|
||||||
@@ -2574,7 +2597,8 @@ public class ExecutionController {
|
|||||||
@Parameter(description = "The namespace")
|
@Parameter(description = "The namespace")
|
||||||
String namespace,
|
String namespace,
|
||||||
@Parameter(description = "The flow's inputs")
|
@Parameter(description = "The flow's inputs")
|
||||||
List<ApiInputAndValue> inputs
|
List<ApiInputAndValue> inputs,
|
||||||
|
List<ApiCheckFailure> checks
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@Introspected
|
@Introspected
|
||||||
@@ -2597,10 +2621,20 @@ public class ExecutionController {
|
|||||||
@Parameter(description = "The error message")
|
@Parameter(description = "The error message")
|
||||||
String message
|
String message
|
||||||
) {
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Introspected
|
||||||
|
public record ApiCheckFailure(
|
||||||
|
@Parameter(description = "The message")
|
||||||
|
String message,
|
||||||
|
@Parameter(description = "The message style")
|
||||||
|
Check.Style style,
|
||||||
|
@Parameter(description = "The behavior")
|
||||||
|
Check.Behavior behavior
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ApiValidateExecutionInputsResponse of(String id, String namespace, List<InputAndValue> inputs) {
|
public static ApiValidateExecutionInputsResponse of(String id, String namespace, List<Check> checks, List<InputAndValue> inputs) {
|
||||||
return new ApiValidateExecutionInputsResponse(
|
return new ApiValidateExecutionInputsResponse(
|
||||||
id,
|
id,
|
||||||
namespace,
|
namespace,
|
||||||
@@ -2615,7 +2649,8 @@ public class ExecutionController {
|
|||||||
.map(cv -> new ApiInputError(cv.getMessage()))
|
.map(cv -> new ApiInputError(cv.getMessage()))
|
||||||
.toList()
|
.toList()
|
||||||
).orElse(List.of())
|
).orElse(List.of())
|
||||||
)).toList()
|
)).toList(),
|
||||||
|
checks.stream().map(check -> new ApiCheckFailure(check.getMessage(), check.getStyle(), check.getBehavior())).toList()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user