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.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(

View File

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

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 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>

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

View File

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

View File

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

View File

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