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:
Florian Hussonnois
2025-11-19 13:44:18 +01:00
committed by Florian Hussonnois
parent 90ee720d49
commit eb51c5be37
9 changed files with 451 additions and 17 deletions

View File

@@ -11,6 +11,7 @@ import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector;
import io.kestra.core.exceptions.InternalException;
import io.kestra.core.models.HasUID;
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.listeners.Listener;
import io.kestra.core.models.tasks.FlowableTask;
@@ -129,6 +130,14 @@ public class Flow extends AbstractFlow implements HasUID {
@Valid
@PluginProperty
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() {
return Stream.of(

View File

@@ -43,6 +43,7 @@ public class FlowWithSource extends Flow {
.concurrency(this.concurrency)
.retry(this.retry)
.sla(this.sla)
.checks(this.checks)
.build();
}
@@ -85,6 +86,7 @@ public class FlowWithSource extends Flow {
.concurrency(flow.concurrency)
.retry(flow.retry)
.sla(flow.sla)
.checks(flow.checks)
.build();
}
}

View 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);
}
}

View File

@@ -2,20 +2,26 @@ package io.kestra.core.services;
import com.fasterxml.jackson.core.JsonProcessingException;
import io.kestra.core.exceptions.FlowProcessingException;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
import io.kestra.core.models.executions.Execution;
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.topologies.FlowTopology;
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.ValidateConstraintViolation;
import io.kestra.core.plugins.PluginRegistry;
import io.kestra.core.repositories.FlowRepositoryInterface;
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.utils.ListUtils;
import io.kestra.plugin.core.flow.Pause;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import jakarta.inject.Singleton;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
@@ -27,6 +33,7 @@ import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
@@ -53,6 +60,9 @@ public class FlowService {
@Inject
Optional<FlowTopologyRepositoryInterface> flowTopologyRepository;
@Inject
Provider<RunContextFactory> runContextFactory; // Lazy init: avoid circular dependency error.
/**
* Validates and creates the given flow.
@@ -84,7 +94,51 @@ public class FlowService {
return 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.
* <p>

View File

@@ -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);
}
}

View File

@@ -1,11 +1,14 @@
package io.kestra.core.services;
import io.kestra.core.exceptions.FlowProcessingException;
import io.kestra.core.exceptions.IllegalVariableEvaluationException;
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.FlowWithSource;
import io.kestra.core.models.flows.GenericFlow;
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.property.Property;
import io.kestra.core.models.validations.ValidateConstraintViolation;
@@ -17,11 +20,14 @@ import org.junit.jupiter.api.Test;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@KestraTest
class FlowServiceTest {
@@ -431,4 +437,106 @@ class FlowServiceTest {
assertThat(results).hasSize(1);
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);
}
}

View File

@@ -4,9 +4,22 @@
<strong>{{ $t('disabled flow title') }}</strong><br>
{{ $t('disabled flow desc') }}
</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">
<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-item :title="$t('advanced configuration')" name="advanced">
@@ -48,7 +61,7 @@
<el-button
:data-test-id="buttonTestId"
:icon="buttonIcon"
:disabled="!flowCanBeExecuted"
:disabled="!flowCanBeExecuted || hasBlockingChecks()"
:class="{'flow-run-trigger-button': true, 'onboarding-glow': coreStore.guidedProperties.tourStarted}"
type="primary"
nativeType="submit"
@@ -113,6 +126,7 @@
collapseName: undefined,
newTab: localStorage.getItem(storageKeys.EXECUTE_FLOW_BEHAVIOUR) === executeFlowBehaviours.NEW_TAB,
executeClicked: false,
checks: []
};
},
emits: ["executionTrigger", "updateInputs", "updateLabels"],
@@ -141,6 +155,9 @@
}
},
methods: {
hasBlockingChecks() {
return this.checks.filter(check => check.behavior === "BLOCK_EXECUTION").length > 0;
},
getExecutionLabels() {
if (!this.execution.labels) {
return [];
@@ -243,6 +260,9 @@
</script>
<style scoped lang="scss">
.flow-execution-checks-alerts {
margin-bottom: 1rem;
}
:deep(.el-collapse) {
border-radius: var(--bs-border-radius-lg);
border: 1px solid var(--ks-border-primary);

View File

@@ -341,10 +341,10 @@
DeleteOutline,
Pencil,
Plus,
ContentSave,
ContentSave
};
},
emits: ["update:modelValue", "update:modelValueNoDefault", "confirm", "validation"],
emits: ["update:modelValue", "update:modelValueNoDefault", "update:checks", "confirm", "validation"],
created() {
this.inputsMetaData = JSON.parse(JSON.stringify(this.initialInputs));
this.debouncedValidation = debounce(this.validateInputs, 500)
@@ -391,6 +391,7 @@
document.removeEventListener("keydown", this._keyListener);
},
methods: {
normalizeJSON(value) {
try {
// Step 1: Remove trailing commas in objects and arrays
@@ -537,6 +538,7 @@
const formData = inputsToFormData(this, this.inputsMetaData, inputsValuesWithNoDefault);
const metadataCallback = (response) => {
this.$emit("update:checks", response.checks || []);
this.inputsMetaData = response.inputs.reduce((acc,it) => {
if(it.enabled){
acc.push({...it.input, errors: it.errors, value: it.value || it.input.prefill, isDefault: it.isDefault});

View File

@@ -10,6 +10,7 @@ import io.kestra.core.models.Label;
import io.kestra.core.models.QueryFilter;
import io.kestra.core.models.executions.*;
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.hierarchies.FlowGraph;
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.lang3.exception.ExceptionUtils;
import org.reactivestreams.Publisher;
import org.slf4j.event.Level;
import reactor.core.publisher.Flux;
import reactor.core.publisher.FluxSink;
import reactor.core.publisher.Mono;
@@ -190,14 +192,14 @@ public class ExecutionController {
@Inject
private Optional<OpenTelemetry> openTelemetry;
@Inject
private ExecutionStreamingService executionStreamingService;
@Inject
private LocalPathFactory localPathFactory;
@Inject
private SecureVariableRendererFactory secureVariableRendererFactory;
@Inject
private LogService logService;
@Value("${" + LocalPath.ENABLE_PREVIEW_CONFIG + ":true}")
private boolean enableLocalFilePreview;
@@ -691,7 +693,11 @@ public class ExecutionController {
Execution execution = Execution.newExecution(flow, parsedLabels);
return flowInputOutput
.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)
@@ -729,7 +735,24 @@ public class ExecutionController {
return flowInputOutput.readExecutionInputs(flow, current, inputs)
.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 {
// inject the traceparent into the execution
openTelemetry
@@ -740,7 +763,7 @@ public class ExecutionController {
executionQueue.emit(executionWithInputs);
eventPublisher.publishEvent(new CrudEvent<>(executionWithInputs, CrudEventType.CREATE));
if (!wait) {
if (!wait || executionWithInputs.getState().isFailed()) {
return Mono.just(ExecutionResponse.fromExecution(
executionWithInputs,
executionUrl(executionWithInputs)
@@ -1457,7 +1480,7 @@ public class ExecutionController {
Flow flow = flowRepository.findByExecutionWithoutAcl(execution);
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
.doOnError(t -> Flux.from(inputs).subscribeOn(Schedulers.boundedElastic()).blockLast());
}
@@ -2574,7 +2597,8 @@ public class ExecutionController {
@Parameter(description = "The namespace")
String namespace,
@Parameter(description = "The flow's inputs")
List<ApiInputAndValue> inputs
List<ApiInputAndValue> inputs,
List<ApiCheckFailure> checks
) {
@Introspected
@@ -2597,10 +2621,20 @@ public class ExecutionController {
@Parameter(description = "The error 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(
id,
namespace,
@@ -2615,7 +2649,8 @@ public class ExecutionController {
.map(cv -> new ApiInputError(cv.getMessage()))
.toList()
).orElse(List.of())
)).toList()
)).toList(),
checks.stream().map(check -> new ApiCheckFailure(check.getMessage(), check.getStyle(), check.getBehavior())).toList()
);
}
}