This is an automated email from the ASF dual-hosted git repository.
github-merge-queue[bot] pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git
The following commit(s) were added to refs/heads/dev by this push:
new ce4cde54c8 [Improve][Core] Add reusable config validation result
(#12065)
ce4cde54c8 is described below
commit ce4cde54c8abc7a6a9e3831110dbab029512ef7b
Author: Yohanes <[email protected]>
AuthorDate: Fri Sep 4 21:55:00 2026 +0000
[Improve][Core] Add reusable config validation result (#12065)
Co-authored-by: Yohanes <[email protected]>
---
.../starter/validation/ConfigValidationError.java | 74 +++++++++++++
.../starter/validation/ConfigValidationResult.java | 116 +++++++++++++++++++++
.../validation/ConfigValidationResultTest.java | 66 ++++++++++++
.../command/SeaTunnelConfValidateCommand.java | 90 ++++++++++++++++
.../command/SeaTunnelConfValidateCommandTest.java | 111 +++++++++++++++++++-
5 files changed, 456 insertions(+), 1 deletion(-)
diff --git
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationError.java
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationError.java
new file mode 100644
index 0000000000..fb57bfffbe
--- /dev/null
+++
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationError.java
@@ -0,0 +1,74 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.core.starter.validation;
+
+import java.io.Serializable;
+
+/** A machine-readable validation failure at config level. */
+public final class ConfigValidationError implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /** Plugin location, such as {@code source[0](FakeSource)}, when the phase
can identify it. */
+ private final String location;
+
+ /** Plugin factory identifier parsed from {@link #location}, when
available. */
+ private final String plugin;
+
+ /** Option or option group reported by the underlying option validator,
when available. */
+ private final String optionPath;
+
+ /** Stable, closed category describing the validation rule that failed. */
+ private final String ruleCategory;
+
+ /** Sanitized diagnostic message suitable for programmatic consumers. */
+ private final String message;
+
+ public ConfigValidationError(
+ String location,
+ String plugin,
+ String optionPath,
+ String ruleCategory,
+ String message) {
+ this.location = location;
+ this.plugin = plugin;
+ this.optionPath = optionPath;
+ this.ruleCategory = ruleCategory;
+ this.message = message;
+ }
+
+ public String getLocation() {
+ return location;
+ }
+
+ public String getPlugin() {
+ return plugin;
+ }
+
+ public String getOptionPath() {
+ return optionPath;
+ }
+
+ public String getRuleCategory() {
+ return ruleCategory;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
diff --git
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResult.java
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResult.java
new file mode 100644
index 0000000000..49ea630661
--- /dev/null
+++
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResult.java
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.core.starter.validation;
+
+import
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ArrayNode;
+import
org.apache.seatunnel.shade.com.fasterxml.jackson.databind.node.ObjectNode;
+
+import org.apache.seatunnel.common.utils.JsonUtils;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Versioned, config-level validation result shared by CLI and future adapters.
+ *
+ * <p>This model intentionally describes static/config validation only. It
does not imply that a
+ * connector can reach its external system or that a generated job is
runtime-equivalent.
+ */
+public final class ConfigValidationResult implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+ public static final String SCHEMA_VERSION = "1.0";
+
+ private final boolean valid;
+ private final String phase;
+ private final List<ConfigValidationError> errors;
+
+ private ConfigValidationResult(
+ boolean valid, String phase, List<ConfigValidationError> errors) {
+ this.valid = valid;
+ this.phase = phase;
+ this.errors = Collections.unmodifiableList(new ArrayList<>(errors));
+ }
+
+ public static ConfigValidationResult success(String phase) {
+ return new ConfigValidationResult(true, phase,
Collections.emptyList());
+ }
+
+ public static ConfigValidationResult failure(String phase,
ConfigValidationError error) {
+ return new ConfigValidationResult(false, phase,
Collections.singletonList(error));
+ }
+
+ public boolean isValid() {
+ return valid;
+ }
+
+ public String getPhase() {
+ return phase;
+ }
+
+ public List<ConfigValidationError> getErrors() {
+ return errors;
+ }
+
+ /** Serialize fields in a fixed order so adapters can rely on a stable
shape. */
+ public String toJson() {
+ ObjectNode root = JsonUtils.createObjectNode();
+ root.put("schemaVersion", SCHEMA_VERSION);
+ root.put("valid", valid);
+ root.put("phase", phase);
+ ArrayNode errorNodes = root.putArray("errors");
+ for (ConfigValidationError error : errors) {
+ ObjectNode errorNode = errorNodes.addObject();
+ putNullable(errorNode, "location", error.getLocation());
+ putNullable(errorNode, "plugin", error.getPlugin());
+ putNullable(errorNode, "optionPath", error.getOptionPath());
+ putNullable(errorNode, "ruleCategory", error.getRuleCategory());
+ putNullable(errorNode, "message", error.getMessage());
+ }
+ return root.toString();
+ }
+
+ /** Preserve the existing --check message for the CLI adapter. */
+ public String toHumanReadable() {
+ if (valid) {
+ return "VALID";
+ }
+ String message = errors.isEmpty() ? "Validation failed" :
errors.get(0).getMessage();
+ return humanPhase(phase) + " failed: " + message;
+ }
+
+ private static String humanPhase(String phase) {
+ if ("connectivity".equals(phase)) {
+ return "Connectivity check";
+ }
+ if ("static".equals(phase)) {
+ return "Static analysis";
+ }
+ return phase;
+ }
+
+ private static void putNullable(ObjectNode node, String name, String
value) {
+ if (value == null) {
+ node.putNull(name);
+ } else {
+ node.put(name, value);
+ }
+ }
+}
diff --git
a/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResultTest.java
b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResultTest.java
new file mode 100644
index 0000000000..9d15fde2ad
--- /dev/null
+++
b/seatunnel-core/seatunnel-core-starter/src/test/java/org/apache/seatunnel/core/starter/validation/ConfigValidationResultTest.java
@@ -0,0 +1,66 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.seatunnel.core.starter.validation;
+
+import org.apache.seatunnel.common.utils.JsonUtils;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+public class ConfigValidationResultTest {
+
+ @Test
+ void serializesSuccessWithStableSchema() throws Exception {
+ ConfigValidationResult result =
ConfigValidationResult.success("static");
+
+ Assertions.assertEquals(
+ "{\"schemaVersion\":\"1.0\",\"valid\":true,"
+ + "\"phase\":\"static\",\"errors\":[]}",
+ result.toJson());
+ Assertions.assertTrue(
+ JsonUtils.readTree(
+
result.toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .get("valid")
+ .asBoolean());
+ Assertions.assertEquals("VALID", result.toHumanReadable());
+ }
+
+ @Test
+ void serializesFailureAndPreservesNullableFields() throws Exception {
+ ConfigValidationError error =
+ new ConfigValidationError(
+ "source[0](Kafka)", "Kafka", null, "option", "Required
option is missing");
+ ConfigValidationResult result =
ConfigValidationResult.failure("static", error);
+
+ Assertions.assertEquals(
+ "{\"schemaVersion\":\"1.0\",\"valid\":false,"
+ + "\"phase\":\"static\",\"errors\":[{"
+ +
"\"location\":\"source[0](Kafka)\",\"plugin\":\"Kafka\","
+ + "\"optionPath\":null,\"ruleCategory\":\"option\","
+ + "\"message\":\"Required option is missing\"}]}",
+ result.toJson());
+ Assertions.assertEquals(
+ "Static analysis failed: Required option is missing",
result.toHumanReadable());
+ Assertions.assertEquals(
+ "option",
+ JsonUtils.readTree(
+
result.toJson().getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .at("/errors/0/ruleCategory")
+ .asText());
+ }
+}
diff --git
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommand.java
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommand.java
index b9bc0c5333..a1b77575e1 100644
---
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommand.java
+++
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommand.java
@@ -39,6 +39,8 @@ import
org.apache.seatunnel.core.starter.exception.ConfigCheckException;
import org.apache.seatunnel.core.starter.seatunnel.args.ClientCommandArgs;
import org.apache.seatunnel.core.starter.utils.ConfigBuilder;
import org.apache.seatunnel.core.starter.utils.FileUtils;
+import org.apache.seatunnel.core.starter.validation.ConfigValidationError;
+import org.apache.seatunnel.core.starter.validation.ConfigValidationResult;
import org.apache.seatunnel.engine.core.parse.ConfigParserUtil;
import org.apache.seatunnel.engine.core.parse.JobPluginClasspathHelper;
@@ -50,6 +52,9 @@ import java.net.URL;
import java.nio.file.Path;
import java.util.Collections;
import java.util.List;
+import java.util.Locale;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
import static
org.apache.seatunnel.api.options.ConnectorCommonOptions.PLUGIN_NAME;
@@ -83,6 +88,11 @@ import static
org.apache.seatunnel.api.options.ConnectorCommonOptions.PLUGIN_NAM
@Slf4j
public class SeaTunnelConfValidateCommand implements
Command<ClientCommandArgs> {
+ private static final Pattern PLUGIN_LOCATION_PATTERN =
+
Pattern.compile("((?:source|transform|sink)\\[\\d+\\]\\([^)]*\\))");
+ private static final Pattern OPTION_PATH_PATTERN =
+ Pattern.compile("(?m)^\\s*options?:\\s*([^\\r\\n]+)");
+
private final ClientCommandArgs clientCommandArgs;
public SeaTunnelConfValidateCommand(ClientCommandArgs clientCommandArgs) {
@@ -187,6 +197,86 @@ public class SeaTunnelConfValidateCommand implements
Command<ClientCommandArgs>
}
}
+ /**
+ * Validate the configuration and return a reusable result for non-CLI
integrations.
+ *
+ * <p>The result is deliberately config-level and does not claim
runtime-equivalent validation.
+ */
+ public ConfigValidationResult validateResult() {
+ try {
+ execute();
+ return ConfigValidationResult.success(validationPhase());
+ } catch (ConfigCheckException e) {
+ String message = e.getMessage();
+ String prefix = validationMode() + " failed: ";
+ if (message != null && message.startsWith(prefix)) {
+ message = message.substring(prefix.length());
+ }
+ // The result is intended for programmatic consumers, so never
expose credentials
+ // even when the underlying validation phase is static.
+ message = DryRunConnectFailureMessageSanitizer.sanitize(message);
+ return ConfigValidationResult.failure(
+ validationPhase(),
+ toValidationError(message == null ? "Validation failed" :
message));
+ }
+ }
+
+ private String validationPhase() {
+ return clientCommandArgs.getDryRun() == DryRun.CONNECT ?
"connectivity" : "static";
+ }
+
+ private String validationMode() {
+ return clientCommandArgs.getDryRun() == DryRun.CONNECT
+ ? "Connectivity check"
+ : "Static analysis";
+ }
+
+ private ConfigValidationError toValidationError(String message) {
+ String location = null;
+ String plugin = null;
+ Matcher locationMatcher = PLUGIN_LOCATION_PATTERN.matcher(message);
+ if (locationMatcher.find()) {
+ location = locationMatcher.group(1);
+ int open = location.lastIndexOf('(');
+ plugin = location.substring(open + 1, location.length() - 1);
+ }
+
+ Matcher optionPathMatcher = OPTION_PATH_PATTERN.matcher(message);
+ String optionPath = optionPathMatcher.find() ?
optionPathMatcher.group(1).trim() : null;
+
+ String lower = message.toLowerCase(Locale.ROOT);
+ ValidationRuleCategory ruleCategory;
+ if (lower.contains("parse") || lower.contains("syntax") ||
lower.contains("hocon")) {
+ ruleCategory = ValidationRuleCategory.PARSE;
+ } else if (lower.contains("option")
+ || lower.contains("required")
+ || lower.contains("unknown key")
+ || lower.contains("type")) {
+ ruleCategory = ValidationRuleCategory.OPTION;
+ } else if (lower.contains("plugin")
+ || lower.contains("factory")
+ || lower.contains("classloader")) {
+ ruleCategory = ValidationRuleCategory.PLUGIN;
+ } else {
+ ruleCategory = ValidationRuleCategory.VALIDATION;
+ }
+ return new ConfigValidationError(location, plugin, optionPath,
ruleCategory.value, message);
+ }
+
+ /** Closed categories exposed by the current structured validation result
schema. */
+ private enum ValidationRuleCategory {
+ PARSE("parse"),
+ OPTION("option"),
+ PLUGIN("plugin"),
+ VALIDATION("validation");
+
+ private final String value;
+
+ ValidationRuleCategory(String value) {
+ this.value = value;
+ }
+ }
+
private void validateOptionTypes(ReadonlyConfig config, OptionRule rule) {
if (rule == null) {
return;
diff --git
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java
index 1724b0eb00..d44673bf1a 100644
---
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java
+++
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/SeaTunnelConfValidateCommandTest.java
@@ -25,6 +25,7 @@ import
org.apache.seatunnel.core.starter.exception.ConfigCheckException;
import org.apache.seatunnel.core.starter.seatunnel.args.ClientCommandArgs;
import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
import org.apache.seatunnel.core.starter.utils.ConfigBuilder;
+import org.apache.seatunnel.core.starter.validation.ConfigValidationResult;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -49,6 +50,110 @@ public class SeaTunnelConfValidateCommandTest {
Assertions.assertDoesNotThrow(command::execute);
}
+ @Test
+ public void testValidationResultForValidConfig() {
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildArgs("config/valid_static_dryrun.json"));
+
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertTrue(result.isValid());
+ Assertions.assertEquals("static", result.getPhase());
+ Assertions.assertTrue(result.getErrors().isEmpty());
+ }
+
+ @Test
+ public void testValidationResultClassifiesMissingPluginAsOptionError()
throws Exception {
+ Path configFile = Files.createTempFile("seatunnel-validation-result",
".conf");
+ Files.write(
+ configFile,
+ ("source { FakeSource { plugin_output = output } }\n" + "sink
{ InMemory {} }")
+ .getBytes(StandardCharsets.UTF_8));
+ configFile.toFile().deleteOnExit();
+
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildArgsFromPath(configFile.toString()));
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertFalse(result.isValid());
+ Assertions.assertEquals(1, result.getErrors().size());
+ Assertions.assertEquals("option",
result.getErrors().get(0).getRuleCategory());
+
Assertions.assertTrue(result.toJson().contains("\"schemaVersion\":\"1.0\""));
+ }
+
+ @Test
+ public void testValidationResultClassifiesParseFailure() {
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildArgs("config/invalid_hocon_syntax.conf"));
+
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertFalse(result.isValid());
+ Assertions.assertEquals("parse",
result.getErrors().get(0).getRuleCategory());
+ }
+
+ @Test
+ public void testValidationResultClassifiesOptionFailure() {
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildArgs("config/invalid_option_type.json"));
+
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertFalse(result.isValid());
+ Assertions.assertEquals("option",
result.getErrors().get(0).getRuleCategory());
+ }
+
+ @Test
+ public void testValidationResultIncludesConnectFailureLocationAndPlugin()
throws Exception {
+ Path configFile =
Files.createTempFile("seatunnel-validation-connect-failure", ".conf");
+ Files.write(
+ configFile,
+ ("source { DryRunTestSource { fail_connection = true } }\n"
+ + "sink { InMemory {} }")
+ .getBytes(StandardCharsets.UTF_8));
+ configFile.toFile().deleteOnExit();
+
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildConnectArgsFromPath(configFile.toString()));
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertFalse(result.isValid());
+ Assertions.assertEquals("connectivity", result.getPhase());
+ Assertions.assertEquals(
+ "source[0](DryRunTestSource)",
result.getErrors().get(0).getLocation());
+ Assertions.assertEquals("DryRunTestSource",
result.getErrors().get(0).getPlugin());
+ }
+
+ @Test
+ public void testValidationResultSanitizesConnectFailure() throws Exception
{
+ Path configFile =
Files.createTempFile("seatunnel-validation-sensitive-failure", ".conf");
+ Files.write(
+ configFile,
+ ("source { DryRunTestSource { sensitive_connection_failure =
true } }\n"
+ + "sink { InMemory {} }")
+ .getBytes(StandardCharsets.UTF_8));
+ configFile.toFile().deleteOnExit();
+
+ SeaTunnelConfValidateCommand command =
+ new
SeaTunnelConfValidateCommand(buildConnectArgsFromPath(configFile.toString()));
+ String message =
command.validateResult().getErrors().get(0).getMessage();
+
+ Assertions.assertFalse(message.contains("secret-password"), message);
+ Assertions.assertFalse(message.contains("secret-token"), message);
+ }
+
+ @Test
+ public void testValidationResultClassifiesPluginLoadFailure() {
+ SeaTunnelConfValidateCommand command =
+ new SeaTunnelConfValidateCommand(
+ buildArgs("config/invalid_plugin_loadability.json"));
+
+ ConfigValidationResult result = command.validateResult();
+
+ Assertions.assertFalse(result.isValid());
+ Assertions.assertEquals("plugin",
result.getErrors().get(0).getRuleCategory());
+ }
+
@Test
public void testValidConnectDryRun() {
ClientCommandArgs args =
buildConnectArgs("config/valid_static_dryrun.json");
@@ -497,7 +602,11 @@ public class SeaTunnelConfValidateCommandTest {
}
private ClientCommandArgs buildArgs(String configFile) {
- String[] args = {"-c", resolveConfigPath(configFile), "--dry-run",
"static"};
+ return buildArgsFromPath(resolveConfigPath(configFile));
+ }
+
+ private ClientCommandArgs buildArgsFromPath(String configPath) {
+ String[] args = {"-c", configPath, "--dry-run", "static"};
return CommandLineUtils.parse(args, new ClientCommandArgs(),
"seatunnel.sh", true);
}