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 c389c24b0e [Fix][Core] Fix application CLI validation and status
handling (#12898)
c389c24b0e is described below
commit c389c24b0ec24c55dafbf2d0e9d98189474d3e2f
Author: zhiweiniu <[email protected]>
AuthorDate: Tue Oct 27 12:09:05 2026 +0000
[Fix][Core] Fix application CLI validation and status handling (#12898)
---
docs/en/getting-started/application/cli.md | 32 ++--
.../application/kubernetes/commands.md | 15 +-
.../introduction/concepts/incompatible-changes.md | 6 +
docs/zh/getting-started/application/cli.md | 29 +++-
.../application/kubernetes/commands.md | 15 +-
.../introduction/concepts/incompatible-changes.md | 6 +
.../core/starter/utils/CommandLineUtils.java | 31 +++-
.../starter/seatunnel/SeaTunnelApplication.java | 15 +-
.../core/starter/seatunnel/SeaTunnelClient.java | 5 +-
.../seatunnel/args/ApplicationCommandArgs.java | 54 +++++--
.../command/ApplicationExecuteCommand.java | 17 ++-
.../starter/seatunnel/SeaTunnelClientOOMTest.java | 3 +-
.../seatunnel/args/ClientCommandArgsTest.java | 23 +++
.../command/ApplicationExecuteCommandTest.java | 166 ++++++++++++++++++++-
.../engine/common/runtime/ApplicationStatus.java | 4 +-
15 files changed, 362 insertions(+), 59 deletions(-)
diff --git a/docs/en/getting-started/application/cli.md
b/docs/en/getting-started/application/cli.md
index cae235e005..cdcf9229c0 100644
--- a/docs/en/getting-started/application/cli.md
+++ b/docs/en/getting-started/application/cli.md
@@ -6,13 +6,13 @@ title: Command Line
# Application command line
`bin/seatunnel-application.sh` is the only entry point of Application Mode. It
exposes three
-operations, all of which target one external resource platform and read the
same deployment file,
+operations, all of which target one external resource platform using matching
deployment options,
so `status` and `cancel` see exactly the application `submit` created.
## Synopsis
```bash
-bin/seatunnel-application.sh submit -t <target> -a application.config [-c
job.conf]
+bin/seatunnel-application.sh submit -t <target> [-a application.config]
[-Dkey=value ...] [-c job.conf]
bin/seatunnel-application.sh status -t <target> --id APPLICATION_ID -a
application.config [--wait] \
[--wait-timeout <ms>] [--wait-interval <ms>]
bin/seatunnel-application.sh cancel -t <target> --id APPLICATION_ID -a
application.config
@@ -57,7 +57,7 @@ Usage: seatunnel-application.sh [options] submit | status |
cancel
| `submit` / `status` / `cancel` | all | Operation, the first positional
argument |
| `-t`, `--target` | all | Resource platform that runs the application |
| `-c`, `--config` | submit | SeaTunnel job configuration file; optional when
the platform carries the job, such as a mounted `job.conf` |
-| `-a`, `--application-config` | all | Optional deployment file with common
and platform options |
+| `-a`, `--application-config` | all | Optional deployment file with common
and platform options; options can also be supplied through `-D` |
| `-Dkey=value` | all | Override one non-sensitive `application.*` or
`kubernetes.*` deployment option, for example `-Dapplication.worker-count=4`;
repeatable. Job `${...}` variables are resolved inside the Pod, not on the
submitting host |
| `--id` | status, cancel | Platform application ID printed by `submit` |
| `--wait` | submit, status | Poll until a terminal status |
@@ -71,7 +71,7 @@ Usage: seatunnel-application.sh [options] submit | status |
cancel
| Operation | What it does | Result |
| --- | --- | --- |
| `submit` | Localizes the job, creates the application on the platform and
starts it | Prints the application ID and the native job ID |
-| `status` | Reads the platform state and maps it to an `ApplicationStatus` |
Prints the application ID and status; exits non-zero when the application
failed |
+| `status` | Reads the platform state and maps it to an `ApplicationStatus` |
Prints the application ID and status; a successful plain query exits `0`; with
`--wait`, follows the [waiting exit codes](#status-and-waiting) |
| `cancel` | Removes the application and the resources it owns | Prints the
application ID and the cancellation result |
## Submit returns immediately
@@ -89,6 +89,10 @@ The job comes from `-c` or from a file the platform mounts
inside the applicatio
resource is created. Values written as `${VAR}` are resolved inside the
running application, never
on the submitting host, so platform-managed credentials can be injected
through the environment.
+The deployment file (`-a`) is optional when `-D` supplies the required
deployment options.
+When both are given, `-D` overrides file values. Platform validation still
applies: omitting
+`-a` does not make incomplete deployment settings valid.
+
## Status and waiting
`status` reports one snapshot by default and polls that snapshot until it
becomes terminal when
@@ -106,14 +110,24 @@ often the status is polled. The platform-independent
states are:
| `CANCELED` | The platform reported a cancellation |
| `UNKNOWN` | A known application has a platform state that cannot yet be
mapped |
-`SUCCEEDED`, `FAILED` and `CANCELED` are terminal; `--wait` stops there.
`FAILED`, `CANCELED` and
-`UNKNOWN` make `status` exit non-zero, which turns the command into a
completion gate for
-schedulers and CI:
+`SUCCEEDED`, `FAILED` and `CANCELED` are terminal; `--wait` stops there. It
also stops at
+`UNKNOWN` when the platform cannot determine the application outcome. For both
`status --wait`
+and `submit --wait`, `FAILED` exits with code `1`, while `UNKNOWN` exits with
code `2`: the result
+is indeterminate, not a confirmed failure or success. `SUCCEEDED` and
`CANCELED` still exit `0`.
+A plain `status` prints the snapshot and exits `0`, including for `FAILED` and
`UNKNOWN`;
+query errors still exit non-zero. Do not interpret every non-zero exit as a
failed application:
```bash
-if ! bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a
application.config --wait; then
- echo "application $APP_ID did not succeed"
+if bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a
application.config --wait; then
+ result=0
+else
+ result=$?
fi
+case "$result" in
+ 0) echo "application status reported; inspect the printed status" ;;
+ 2) echo "application $APP_ID outcome is unknown" ;;
+ *) echo "application failed or the command could not complete (exit
$result)" ;;
+esac
```
## Cancel
diff --git a/docs/en/getting-started/application/kubernetes/commands.md
b/docs/en/getting-started/application/kubernetes/commands.md
index 378eae110b..29bcf1d16f 100644
--- a/docs/en/getting-started/application/kubernetes/commands.md
+++ b/docs/en/getting-started/application/kubernetes/commands.md
@@ -44,8 +44,11 @@ Application ID: example-9f2c41a0b7d3
Status: RUNNING
```
-`--wait` keeps polling until a terminal status is reported; `FAILED`,
`CANCELED` and `UNKNOWN` make
-the command exit non-zero, which turns it into a completion gate for
schedulers and CI.
+A plain `status` prints the snapshot and exits `0` when the query succeeds,
including for `FAILED`
+and `UNKNOWN`; query errors exit non-zero. `--wait` keeps polling until a
terminal status or `UNKNOWN`
+is reported. Both `status --wait` and `submit --wait` exit `1` for `FAILED`,
`2` for `UNKNOWN`
+(an indeterminate outcome, not a confirmed success or failure), and `0` for
`SUCCEEDED` or `CANCELED`.
+See [Status and waiting](../cli.md#status-and-waiting) for the shared
exit-code contract.
`--wait-timeout` bounds that polling and fails with the last observed status
when the limit is
reached, while the default `0` keeps waiting indefinitely; `--wait-interval`
sets the poll interval
(500 ms by default).
@@ -54,10 +57,10 @@ reached, while the default `0` keeps waiting indefinitely;
`--wait-interval` set
`cancel` deletes the owner Job of the application. Owned Pods, Services,
Secrets and ConfigMaps are
garbage-collected through the owner reference; referenced objects created
outside SeaTunnel are
-never modified or deleted. After cancellation the application reports
`CANCELED`, and a finished
-Job is retained until `kubernetes.finished-job-retention-seconds` expires. A
canceled master exits
-with code 1, because a Kubernetes Job cannot express `CANCELED`, so the Job
object ends in the
-`Failed` state while `status` reports `CANCELED`.
+never modified or deleted. The command reports `Cancellation requested` once
the delete is accepted.
+Because the owner Job is removed, a later `status` returns `UNKNOWN`; use
`kubectl` to confirm that
+the Pods are gone. If the delete call fails before the Job is removed, retry
`cancel` after the API
+outage is resolved.
## Inspecting in Kubernetes
diff --git a/docs/en/introduction/concepts/incompatible-changes.md
b/docs/en/introduction/concepts/incompatible-changes.md
index 29958c414f..3984ea546a 100644
--- a/docs/en/introduction/concepts/incompatible-changes.md
+++ b/docs/en/introduction/concepts/incompatible-changes.md
@@ -41,6 +41,12 @@ The same strictness applies wherever the shared JSON-to-row
conversion is used w
- **Impact**: With the defaults, timestamps are written as `yyyy-MM-dd
HH:mm:ss` (a space instead of `T`, fractional seconds dropped) and times as
`HH:mm:ss`. Readers that expect the previous ISO layout must be adjusted.
- **Migration Guide**: To keep an ISO layout, set `datetime_format` on the
sink, for example `YYYY_MM_DD_HH_MM_SS_ISO8601` (`yyyy-MM-dd'T'HH:mm:ss`) or
`YYYY_MM_DD_HH_MM_SS_SSS_ISO8601` (`yyyy-MM-dd'T'HH:mm:ss.SSS`) to keep
milliseconds, and read the files with the matching source `datetime_format`.
+### Native CLI help exit code
+
+`seatunnel.sh -h` now exits with `0` instead of `234`.
+Update scripts that recognize native help by exit code `234`. The Spark/Flink
Java launcher
+protocol still uses `234`; its wrapper scripts continue to translate help to
exit `0`.
+
### CDC: nullable columns are no longer used as the snapshot split column
- **Behavior change: JDBC-based CDC sources (MySQL, PostgreSQL, Oracle, SQL
Server, DB2) no longer split the snapshot on a nullable column**
diff --git a/docs/zh/getting-started/application/cli.md
b/docs/zh/getting-started/application/cli.md
index da7433d3d7..1c19c11f4b 100644
--- a/docs/zh/getting-started/application/cli.md
+++ b/docs/zh/getting-started/application/cli.md
@@ -6,13 +6,13 @@ title: 命令行
# Application 命令行
`bin/seatunnel-application.sh` 是 Application Mode 的唯一入口,只提供三个操作。三者都面向同一个
-外部资源平台并读取同一份部署文件,因此 `status`、`cancel` 看到的正是 `submit` 创建的
+外部资源平台并使用一致的部署选项,因此 `status`、`cancel` 看到的正是 `submit` 创建的
application。
## 命令格式
```bash
-bin/seatunnel-application.sh submit -t <target> -a application.config [-c
job.conf]
+bin/seatunnel-application.sh submit -t <target> [-a application.config]
[-Dkey=value ...] [-c job.conf]
bin/seatunnel-application.sh status -t <target> --id APPLICATION_ID -a
application.config [--wait] \
[--wait-timeout <ms>] [--wait-interval <ms>]
bin/seatunnel-application.sh cancel -t <target> --id APPLICATION_ID -a
application.config
@@ -57,7 +57,7 @@ Usage: seatunnel-application.sh [options] submit | status |
cancel
| `submit` / `status` / `cancel` | 全部 | 操作类型,位于第一个位置参数 |
| `-t`、`--target` | 全部 | 运行 application 的资源平台 |
| `-c`、`--config` | submit | SeaTunnel 作业配置文件;作业由平台提供(如挂载的 `job.conf`)时可省略 |
-| `-a`、`--application-config` | 全部 | 可选部署文件,包含公共选项和平台选项 |
+| `-a`、`--application-config` | 全部 | 包含公共选项和平台选项的可选部署文件;也可通过 `-D` 提供选项 |
| `-Dkey=value` | 全部 | 覆盖单个非敏感的 `application.*` / `kubernetes.*` 部署选项(例如
`-Dapplication.worker-count=4`),可重复;作业 `${...}` 变量在 Pod 内解析,不在提交机替换 |
| `--id` | status、cancel | `submit` 打印的平台 application ID |
| `--wait` | submit、status | 轮询直到终态 |
@@ -71,7 +71,7 @@ Usage: seatunnel-application.sh [options] submit | status |
cancel
| 操作 | 做什么 | 结果 |
| --- | --- | --- |
| `submit` | 完成作业本地化,在平台上创建 application 并启动 | 打印 application ID 与原生作业 ID |
-| `status` | 读取平台状态并映射为 `ApplicationStatus` | 打印 application ID
与状态;application 失败时以非零码退出 |
+| `status` | 读取平台状态并映射为 `ApplicationStatus` | 打印 application ID 与状态;普通查询成功时返回
`0`;带 `--wait` 时遵循[等待退出码规则](#状态与等待) |
| `cancel` | 删除 application 及其自有资源 | 打印 application ID 与取消结果 |
## submit 立即返回
@@ -88,6 +88,9 @@ bin/seatunnel-application.sh submit -t <target> -a
application.config --wait
的值在运行中的 application 内解析,不在提交机解析,因此可以通过环境变量注入平台管理的
凭据。
+通过 `-D` 提供所需部署选项时,可以省略部署文件(`-a`)。两者同时提供时,`-D` 覆盖文件值。
+平台配置校验仍然生效:省略 `-a` 不代表不完整的部署配置可以提交成功。
+
## 状态与等待
`status` 默认只读一次快照;加 `--wait` 后持续轮询直到终态。`--wait-timeout` 可以限制轮询时长,
@@ -104,13 +107,23 @@ bin/seatunnel-application.sh submit -t <target> -a
application.config --wait
| `CANCELED` | 平台报告已取消 |
| `UNKNOWN` | 已知 application 的平台状态暂时无法映射 |
-`SUCCEEDED`、`FAILED`、`CANCELED` 为终态,`--wait` 在终态停止。`FAILED`、`CANCELED` 和
-`UNKNOWN` 会让 `status` 以非零码退出,因此可以直接作为调度器或 CI 的完成判定:
+`SUCCEEDED`、`FAILED`、`CANCELED` 为终态,`--wait` 在终态停止;平台无法确定 application
+结果时(`UNKNOWN`)也会停止。`status --wait` 和 `submit --wait` 在 `FAILED` 时退出码为 `1`,
+在 `UNKNOWN` 时退出码为 `2`,表示无法确定成功或失败,而不是认定应用失败。
+`SUCCEEDED` 和 `CANCELED` 仍返回 `0`。不带 `--wait` 的 `status` 打印快照后返回 `0`,
+包括 `FAILED` 和 `UNKNOWN`;查询异常仍以非零码退出。不要把所有非零退出码都当成应用失败:
```bash
-if ! bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a
application.config --wait; then
- echo "application $APP_ID did not succeed"
+if bin/seatunnel-application.sh status -t <target> --id "$APP_ID" -a
application.config --wait; then
+ result=0
+else
+ result=$?
fi
+case "$result" in
+ 0) echo "application status reported; inspect the printed status" ;;
+ 2) echo "application $APP_ID outcome is unknown" ;;
+ *) echo "application failed or the command could not complete (exit
$result)" ;;
+esac
```
## cancel
diff --git a/docs/zh/getting-started/application/kubernetes/commands.md
b/docs/zh/getting-started/application/kubernetes/commands.md
index a0af9d9aea..2d47a9fb11 100644
--- a/docs/zh/getting-started/application/kubernetes/commands.md
+++ b/docs/zh/getting-started/application/kubernetes/commands.md
@@ -40,17 +40,20 @@ Application ID: example-9f2c41a0b7d3
Status: RUNNING
```
-加 `--wait` 会持续轮询直到终态;`FAILED`、`CANCELED` 和 `UNKNOWN` 会让命令以非零码退出,可直接
-作为调度器或 CI 的完成判定。`--wait-timeout` 可以限制轮询时长,超时后带最后状态报错退出;
+不带 `--wait` 的 `status` 打印状态快照,查询成功时返回 `0`,包括 `FAILED` 和 `UNKNOWN`;
+查询异常以非零码退出。加 `--wait` 会持续轮询直到终态或 `UNKNOWN`。
+`status --wait` 和 `submit --wait` 在 `FAILED` 时退出码为 `1`,在 `UNKNOWN` 时为 `2`
+(表示无法确定成功或失败),在 `SUCCEEDED` 或 `CANCELED` 时为 `0`。
+共享退出码规则见[状态与等待](../cli.md#状态与等待)。
+`--wait-timeout` 可以限制轮询时长,超时后带最后状态报错退出;
默认 `0` 表示一直等待。`--wait-interval` 设置轮询间隔(默认 500 ms)。
## cancel
`cancel` 删除 application 的 owner Job,自有 Pod、Service、Secret 和 ConfigMap 通过 owner
-reference 一并回收;SeaTunnel 之外创建的引用对象不会被修改或删除。取消后 application 状态为
-`CANCELED`,已完成的 Job 保留到 `kubernetes.finished-job-retention-seconds` 到期。
-被取消的 Master 以退出码 1 结束(Kubernetes Job 无法表达 `CANCELED`),因此 Job 对象最终是
-`Failed` 状态,而 `status` 报告 `CANCELED`。
+reference 一并回收;SeaTunnel 之外创建的引用对象不会被修改或删除。删除请求被接受后命令输出
+`Cancellation requested`。由于 owner Job 已被删除,之后再执行 `status` 会返回 `UNKNOWN`;
+可以用 `kubectl` 确认 Pod 已经消失。如果删除前 API 调用失败,请在 API 恢复后重试 `cancel`。
## 用 kubectl 排查
diff --git a/docs/zh/introduction/concepts/incompatible-changes.md
b/docs/zh/introduction/concepts/incompatible-changes.md
index f00ffe9c47..3a654648af 100644
--- a/docs/zh/introduction/concepts/incompatible-changes.md
+++ b/docs/zh/introduction/concepts/incompatible-changes.md
@@ -40,6 +40,12 @@
- **影响**:使用默认配置时,时间戳写成 `yyyy-MM-dd HH:mm:ss`(`T` 改为空格,丢弃小数秒),时间写成
`HH:mm:ss`。依赖原 ISO 格式的下游需要调整。
- **迁移指南**:如需保留 ISO 格式,请在 Sink 上配置 `datetime_format`,例如
`YYYY_MM_DD_HH_MM_SS_ISO8601`(`yyyy-MM-dd'T'HH:mm:ss`),或使用
`YYYY_MM_DD_HH_MM_SS_SSS_ISO8601`(`yyyy-MM-dd'T'HH:mm:ss.SSS`)保留毫秒,并在读取时为
Source 配置对应的 `datetime_format`。
+### 原生 CLI 帮助退出码
+
+`seatunnel.sh -h` 现在以 `0` 而非 `234` 退出。
+依赖退出码 `234` 识别原生帮助的脚本需要调整。Spark/Flink Java 启动器仍使用 `234` 协议,
+对应包装脚本仍将帮助请求转换为退出码 `0`。
+
### CDC:可空列不再用作快照分片列
- **行为变更:基于 JDBC 的 CDC 源(MySQL、PostgreSQL、Oracle、SQL Server、DB2)不再使用可空列切分快照**
diff --git
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
index 01d82302fc..9b884e660f 100644
---
a/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
+++
b/seatunnel-core/seatunnel-core-starter/src/main/java/org/apache/seatunnel/core/starter/utils/CommandLineUtils.java
@@ -55,6 +55,22 @@ public class CommandLineUtils {
String programName,
boolean acceptUnknownOptions,
Consumer<T> commandValidator) {
+ return parse(
+ args, obj, programName, acceptUnknownOptions,
commandValidator, USAGE_EXIT_CODE);
+ }
+
+ /**
+ * Parses arguments with an explicit help exit code. The default overloads
retain the usage exit
+ * code expected by the Spark and Flink launchers; direct entrypoints may
opt in to a zero exit
+ * code for help. Invalid arguments always use the usage exit code.
+ */
+ public static <T extends CommandArgs> T parse(
+ String[] args,
+ T obj,
+ String programName,
+ boolean acceptUnknownOptions,
+ Consumer<T> commandValidator,
+ int helpExitCode) {
List<String> list = Arrays.asList(args);
if (list.contains("-can")
|| list.contains("--cancel")
@@ -76,21 +92,22 @@ public class CommandLineUtils {
jCommander.parse(args);
// The args is not belongs to SeaTunnel, add into engine original
parameters
obj.setOriginalParameters(jCommander.getUnknownOptions());
+ // Help must short-circuit before command validation: a bare
--help has no
+ // operation/id, and validating it would report an unrelated
argument error.
+ if (obj.isHelp()) {
+ exit(jCommander, helpExitCode);
+ }
commandValidator.accept(obj);
} catch (ParameterException e) {
System.err.println(e.getLocalizedMessage());
- exit(jCommander);
- }
-
- if (obj.isHelp()) {
- exit(jCommander);
+ exit(jCommander, USAGE_EXIT_CODE);
}
return obj;
}
- private static void exit(JCommander jCommander) {
+ private static void exit(JCommander jCommander, int exitCode) {
jCommander.setUsageFormatter(new UsageFormatter(jCommander));
jCommander.usage();
- System.exit(USAGE_EXIT_CODE);
+ System.exit(exitCode);
}
}
diff --git
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
index 76f4f37e98..a736a97f05 100644
---
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
+++
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelApplication.java
@@ -20,10 +20,12 @@ package org.apache.seatunnel.core.starter.seatunnel;
import org.apache.seatunnel.common.constants.EngineType;
import org.apache.seatunnel.core.starter.SeaTunnel;
import org.apache.seatunnel.core.starter.seatunnel.args.ApplicationCommandArgs;
+import
org.apache.seatunnel.core.starter.seatunnel.command.ApplicationExecuteCommand;
import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
/** User-facing entrypoint for native Zeta application deployment and
management. */
public final class SeaTunnelApplication {
+ private static final int HELP_EXIT_CODE = 0;
public static void main(String[] args) {
ApplicationCommandArgs applicationCommandArgs =
@@ -32,13 +34,22 @@ public final class SeaTunnelApplication {
new ApplicationCommandArgs(),
EngineType.SEATUNNEL_APPLICATION.getStarterShellName(),
false,
- ApplicationCommandArgs::validateCommandOptions);
+ ApplicationCommandArgs::validateCommandOptions,
+ HELP_EXIT_CODE);
+ final int exitCode;
try {
- SeaTunnel.run(applicationCommandArgs.buildCommand());
+ ApplicationExecuteCommand command =
+ (ApplicationExecuteCommand)
applicationCommandArgs.buildCommand();
+ SeaTunnel.run(command);
+ exitCode = command.getExitCode();
} catch (Exception e) {
System.err.println("Application command failed: " +
e.getMessage());
e.printStackTrace(System.err);
System.exit(1);
+ return;
+ }
+ if (exitCode != 0) {
+ System.exit(exitCode);
}
}
}
diff --git
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
index e9972e8f34..0213d49e2c 100644
---
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
+++
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClient.java
@@ -29,6 +29,8 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class SeaTunnelClient {
+ private static final int HELP_EXIT_CODE = 0;
+
public static void main(String[] args) throws CommandException {
ClientCommandArgs clientCommandArgs =
CommandLineUtils.parse(
@@ -36,7 +38,8 @@ public class SeaTunnelClient {
new ClientCommandArgs(),
EngineType.SEATUNNEL.getStarterShellName(),
true,
- ClientCommandArgs::validateCommandOptions);
+ ClientCommandArgs::validateCommandOptions,
+ HELP_EXIT_CODE);
try {
SeaTunnel.run(clientCommandArgs.buildCommand());
} catch (Error e) {
diff --git
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
index 16c5b6dcd9..472a0f742f 100644
---
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
+++
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/args/ApplicationCommandArgs.java
@@ -118,24 +118,32 @@ public class ApplicationCommandArgs extends CommandArgs {
if (applicationConfig != null) {
requireFile(applicationConfig, "--application-config");
}
- if (waitTimeoutMillis != null && waitTimeoutMillis < 0) {
- throw new ParameterException("--wait-timeout must not be
negative");
- }
- if (waitIntervalMillis != null && waitIntervalMillis < 1) {
- throw new ParameterException("--wait-interval must be positive");
+ validateWaitOptions();
+ if (operation == ApplicationOperation.SUBMIT) {
+ validateSubmitOptions();
+ } else {
+ validateStatusOrCancelOptions();
}
- if (!wait && (waitTimeoutMillis != null || waitIntervalMillis !=
null)) {
- throw new ParameterException("--wait-timeout and --wait-interval
require --wait");
+ }
+
+ /**
+ * Checks an explicitly supplied job file and rejects an application ID
for submission.
+ * Deployment configuration remains optional here so complete {@code -D}
options can supply it.
+ */
+ private void validateSubmitOptions() {
+ if (config != null) {
+ requireFile(config, "--config");
}
- if (operation == ApplicationOperation.SUBMIT) {
- if (config != null) {
- requireFile(config, "--config");
- }
- if (id != null) {
- throw new ParameterException("submit does not accept --id");
- }
- return;
+ if (id != null) {
+ throw new ParameterException("submit does not accept --id");
}
+ }
+
+ /**
+ * Requires a nonblank application ID and rejects submit-only options for
status and cancel.
+ * Only status may wait for completion; cancel does not accept {@code
--wait}.
+ */
+ private void validateStatusOrCancelOptions() {
if (id == null || id.trim().isEmpty()) {
throw new ParameterException("status/cancel require --id");
}
@@ -151,6 +159,22 @@ public class ApplicationCommandArgs extends CommandArgs {
}
}
+ /**
+ * Requires a nonnegative timeout and a positive poll interval when
explicitly supplied. Both
+ * timing options require {@code --wait}; a zero timeout allows indefinite
waiting.
+ */
+ private void validateWaitOptions() {
+ if (waitTimeoutMillis != null && waitTimeoutMillis < 0) {
+ throw new ParameterException("--wait-timeout must not be
negative");
+ }
+ if (waitIntervalMillis != null && waitIntervalMillis < 1) {
+ throw new ParameterException("--wait-interval must be positive");
+ }
+ if (!wait && (waitTimeoutMillis != null || waitIntervalMillis !=
null)) {
+ throw new ParameterException("--wait-timeout and --wait-interval
require --wait");
+ }
+ }
+
/** @return configured wait timeout in milliseconds, or {@code 0} to wait
indefinitely */
public long resolvedWaitTimeoutMillis() {
return waitTimeoutMillis == null ? 0L : waitTimeoutMillis;
diff --git
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
index dfdebb28d7..025a3adfaa 100644
---
a/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
+++
b/seatunnel-core/seatunnel-starter/src/main/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommand.java
@@ -40,7 +40,16 @@ import java.util.concurrent.TimeUnit;
*/
public class ApplicationExecuteCommand implements
Command<ApplicationCommandArgs> {
+ /** The platform cannot determine whether the application succeeded or
failed. */
+ public static final int UNKNOWN_STATUS_EXIT_CODE = 2;
+
private final ApplicationCommandArgs applicationCommandArgs;
+ private int exitCode;
+
+ /** Returns the CLI outcome after execution without changing the reported
application status. */
+ public int getExitCode() {
+ return exitCode;
+ }
public ApplicationExecuteCommand(ApplicationCommandArgs
applicationCommandArgs) {
this.applicationCommandArgs = applicationCommandArgs;
@@ -48,6 +57,7 @@ public class ApplicationExecuteCommand implements
Command<ApplicationCommandArgs
@Override
public void execute() throws CommandExecuteException {
+ exitCode = 0;
try {
Map<String, String> options = resolveOptions();
ClusterClientServiceLoader clientServiceLoader = new
ClusterClientServiceLoader();
@@ -98,7 +108,12 @@ public class ApplicationExecuteCommand implements
Command<ApplicationCommandArgs
try (ClusterDescriptor<ID> descriptor = factory.create(options)) {
ApplicationStatus status = getStatus(descriptor, applicationId);
System.out.println("Status: " + status);
- if (status.isFailure()) {
+ if (applicationCommandArgs.isWait() && status ==
ApplicationStatus.UNKNOWN) {
+ exitCode = UNKNOWN_STATUS_EXIT_CODE;
+ System.err.println(
+ "Application outcome is unknown; success or failure
cannot be determined.");
+ }
+ if (applicationCommandArgs.isWait() && status.isFailure()) {
throw new CommandExecuteException(
"Application finished with an unsuccessful status");
}
diff --git
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
index 6e348a59f6..024c1ec0a2 100644
---
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
+++
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/SeaTunnelClientOOMTest.java
@@ -50,7 +50,8 @@ public class SeaTunnelClientOOMTest {
Mockito.any(ClientCommandArgs.class),
Mockito.anyString(),
Mockito.anyBoolean(),
- Mockito.any()))
+ Mockito.any(),
+ Mockito.eq(0)))
.thenReturn(clientCommandArgs);
// Mock SeaTunnel.run to throw OutOfMemoryError
diff --git
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
index 2d1bb9d9da..3e1fd3504c 100644
---
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
+++
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/args/ClientCommandArgsTest.java
@@ -21,6 +21,7 @@ import org.apache.seatunnel.core.starter.SeaTunnel;
import org.apache.seatunnel.core.starter.enums.DryRun;
import org.apache.seatunnel.core.starter.enums.MasterType;
import org.apache.seatunnel.core.starter.exception.CommandExecuteException;
+import org.apache.seatunnel.core.starter.seatunnel.SeaTunnelClient;
import
org.apache.seatunnel.core.starter.seatunnel.command.ClientExecuteCommand;
import
org.apache.seatunnel.core.starter.seatunnel.multitable.MultiTableSinkTest;
import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
@@ -168,6 +169,28 @@ public class ClientCommandArgsTest {
Assertions.assertEquals(USAGE_EXIT_CODE, statusCode);
}
+ @Test
+ public void testHelpExitsZeroBeforeValidation() throws Exception {
+ int statusCode = catchSystemExit(() -> SeaTunnelClient.main(new
String[] {"--help"}));
+
+ Assertions.assertEquals(0, statusCode);
+ }
+
+ @Test
+ public void testDefaultHelpRetainsUsageExitCodeBeforeValidation() throws
Exception {
+ int statusCode =
+ catchSystemExit(
+ () ->
+ CommandLineUtils.parse(
+ new String[] {"--help"},
+ new ClientCommandArgs(),
+ "legacy-starter",
+ true,
+ ignored -> Assertions.fail("Help must
skip validation")));
+
+ Assertions.assertEquals(USAGE_EXIT_CODE, statusCode);
+ }
+
@Test
public void testSampleDryRunRejectsJobControlOptions() {
String[] args = {"-c", "app.conf", "--master", "local", "--dry-run",
"sample", "--check"};
diff --git
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
index 5478b3367f..0a0dec0b56 100644
---
a/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
+++
b/seatunnel-core/seatunnel-starter/src/test/java/org/apache/seatunnel/core/starter/seatunnel/command/ApplicationExecuteCommandTest.java
@@ -21,7 +21,9 @@ import
org.apache.seatunnel.shade.com.typesafe.config.ConfigFactory;
import org.apache.seatunnel.common.constants.ApplicationOperation;
import org.apache.seatunnel.core.starter.exception.CommandExecuteException;
+import org.apache.seatunnel.core.starter.seatunnel.SeaTunnelApplication;
import org.apache.seatunnel.core.starter.seatunnel.args.ApplicationCommandArgs;
+import org.apache.seatunnel.core.starter.utils.CommandLineUtils;
import
org.apache.seatunnel.engine.client.deployment.ApplicationClusterDeployer;
import
org.apache.seatunnel.engine.client.deployment.ApplicationClusterDescriptorFactory;
import
org.apache.seatunnel.engine.client.deployment.ClusterClientServiceLoader;
@@ -49,12 +51,15 @@ import java.util.Collections;
import java.util.Map;
import java.util.ServiceLoader;
+import static
com.github.stefanbirkner.systemlambda.SystemLambda.catchSystemExit;
+import static
org.apache.seatunnel.core.starter.constants.SeaTunnelStarterConstants.USAGE_EXIT_CODE;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockConstruction;
@@ -310,6 +315,121 @@ class ApplicationExecuteCommandTest {
verify(descriptor, never()).retrieve(any());
}
+ @Test
+ void statusWithoutWaitReportsUnknownWithoutFailing() throws Exception {
+ assertEquals(0, executeStatus(ApplicationStatus.UNKNOWN, false));
+ }
+
+ @Test
+ void statusWithWaitReportsUnknownWithDistinctExitCode() throws Exception {
+ assertEquals(
+ ApplicationExecuteCommand.UNKNOWN_STATUS_EXIT_CODE,
+ executeStatus(ApplicationStatus.UNKNOWN, true));
+ }
+
+ @Test
+ void statusWithWaitReportsCanceledWithoutFailing() throws Exception {
+ assertEquals(0, executeStatus(ApplicationStatus.CANCELED, true));
+ }
+
+ @Test
+ void statusWithoutWaitReportsFailedWithoutFailing() throws Exception {
+ assertEquals(0, executeStatus(ApplicationStatus.FAILED, false));
+ }
+
+ @Test
+ void statusWithWaitFailsOnFailed() {
+ assertThrows(
+ CommandExecuteException.class, () ->
executeStatus(ApplicationStatus.FAILED, true));
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"status", "submit"})
+ void unknownWaitExitsTwoFromApplicationEntrypoint(String operation) throws
Exception {
+ Path application = temporary.resolve("application.config");
+ Files.write(application, "application.name =
demo".getBytes(StandardCharsets.UTF_8));
+ ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+ ApplicationClusterDescriptorFactory<String> factory =
+ mock(ApplicationClusterDescriptorFactory.class);
+ when(factory.create(any())).thenReturn(descriptor);
+
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+ when(descriptor.deployApplication(any())).thenReturn("application_1");
+
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.UNKNOWN);
+ String[] arguments =
+ "submit".equals(operation)
+ ? new String[] {
+ "submit", "-t", "kubernetes", "-a",
application.toString(), "--wait"
+ }
+ : new String[] {
+ "status", "-t", "kubernetes", "--id",
"application_1", "--wait"
+ };
+ try (MockedConstruction<ClusterClientServiceLoader> loaders =
+ mockConstruction(
+ ClusterClientServiceLoader.class,
+ (loader, context) ->
+
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+ .thenReturn(factory))) {
+ assertEquals(2, catchSystemExit(() ->
SeaTunnelApplication.main(arguments)));
+ }
+ verify(descriptor).getApplicationStatus("native-id");
+ verify(descriptor, times("submit".equals(operation) ? 2 : 1)).close();
+ verify(descriptor, never()).cancelApplication(any());
+ }
+
+ @Test
+ void helpExitsZeroBeforeCommandValidation() throws Exception {
+ int statusCode =
+ catchSystemExit(
+ () ->
+ SeaTunnelApplication.main(
+ new String[] {"status", "-t",
"kubernetes", "--help"}));
+ assertEquals(0, statusCode);
+ }
+
+ @Test
+ void submitWithMissingApplicationConfigExitsThroughUsagePath() throws
Exception {
+ int statusCode =
+ catchSystemExit(
+ () ->
+ CommandLineUtils.parse(
+ new String[] {
+ "submit",
+ "-t",
+ "kubernetes",
+ "-a",
+
temporary.resolve("missing.config").toString()
+ },
+ new ApplicationCommandArgs(),
+ "seatunnel-application.sh",
+ false,
+
ApplicationCommandArgs::validateCommandOptions,
+ 0));
+ assertEquals(USAGE_EXIT_CODE, statusCode);
+ }
+
+ private int executeStatus(ApplicationStatus status, boolean wait) throws
Exception {
+ ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+ ApplicationClusterDescriptorFactory<String> factory =
+ mock(ApplicationClusterDescriptorFactory.class);
+
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+ doReturn(descriptor).when(factory).create(any());
+ when(descriptor.getApplicationStatus("native-id")).thenReturn(status);
+ try (MockedConstruction<ClusterClientServiceLoader> loaders =
+ mockConstruction(
+ ClusterClientServiceLoader.class,
+ (loader, context) ->
+
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+ .thenReturn(factory))) {
+ ApplicationCommandArgs args =
+ parse("status", "-t", "kubernetes", "--id",
"application_1");
+ args.setWait(wait);
+ ApplicationExecuteCommand command = (ApplicationExecuteCommand)
args.buildCommand();
+ command.execute();
+ verify(descriptor).close();
+ return command.getExitCode();
+ }
+ }
+
@Test
void waitsForPlatformTerminalState() throws Exception {
ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
@@ -376,7 +496,7 @@ class ApplicationExecuteCommandTest {
ApplicationClusterDescriptorFactory<String> factory =
mock(ApplicationClusterDescriptorFactory.class);
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
- org.mockito.Mockito.doReturn(descriptor).when(factory).create(any());
+ doReturn(descriptor).when(factory).create(any());
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.SUCCEEDED);
try (MockedConstruction<ClusterClientServiceLoader> loaders =
mockConstruction(
@@ -405,6 +525,50 @@ class ApplicationExecuteCommandTest {
verify(descriptor, times(2)).close();
}
+ @ParameterizedTest
+ @ValueSource(booleans = {false, true})
+ void submitsWithDeploymentOverridesOnly(boolean wait) throws Exception {
+ ClusterDescriptor<String> descriptor = mock(ClusterDescriptor.class);
+ ApplicationClusterDescriptorFactory<String> factory =
+ mock(ApplicationClusterDescriptorFactory.class);
+ when(factory.create(any())).thenReturn(descriptor);
+ when(descriptor.deployApplication(any())).thenReturn("application_1");
+
when(factory.parseApplicationId("application_1")).thenReturn("native-id");
+
when(descriptor.getApplicationStatus("native-id")).thenReturn(ApplicationStatus.SUCCEEDED);
+ try (MockedConstruction<ClusterClientServiceLoader> loaders =
+ mockConstruction(
+ ClusterClientServiceLoader.class,
+ (loader, context) ->
+
when(loader.<String>getClusterClientFactory(DeployType.KUBERNETES))
+ .thenReturn(factory))) {
+ ApplicationCommandArgs args =
+ parse(
+ "submit",
+ "-t",
+ "kubernetes",
+ "-Dapplication.name=overrides-only",
+ "-Dapplication.worker-count=3",
+ "-Dkubernetes.image=example:latest",
+
"-Dkubernetes.job-config-file=/etc/seatunnel/job.conf");
+ args.setWait(wait);
+ args.buildCommand().execute();
+ }
+ ArgumentCaptor<ApplicationSpecification> specification =
+ ArgumentCaptor.forClass(ApplicationSpecification.class);
+ verify(descriptor).deployApplication(specification.capture());
+ assertEquals("overrides-only", specification.getValue().getName());
+ assertEquals(3, specification.getValue().getWorkerCount());
+ assertEquals("", specification.getValue().getJobConfig());
+ ArgumentCaptor<Map<String, String>> deploymentOptions =
ArgumentCaptor.forClass(Map.class);
+ verify(factory, times(wait ? 2 :
1)).create(deploymentOptions.capture());
+ assertEquals("example:latest",
deploymentOptions.getValue().get("kubernetes.image"));
+ assertEquals(
+ "/etc/seatunnel/job.conf",
+
deploymentOptions.getValue().get("kubernetes.job-config-file"));
+ verify(descriptor, times(wait ? 1 :
0)).getApplicationStatus("native-id");
+ verify(descriptor, times(wait ? 2 : 1)).close();
+ }
+
@Test
void validatesCommandBeforeLoadingAnyPlatformProvider() throws Exception {
Path deploymentConfig = temporary.resolve("deployment.conf");
diff --git
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
index 80b43607fb..0193e524db 100644
---
a/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
+++
b/seatunnel-engine/seatunnel-engine-common/src/main/java/org/apache/seatunnel/engine/common/runtime/ApplicationStatus.java
@@ -39,8 +39,8 @@ public enum ApplicationStatus {
return this == SUCCEEDED || this == FAILED || this == CANCELED;
}
- /** @return whether this state represents a failed, canceled, or unknown
application */
+ /** @return whether this state represents a failed application */
public boolean isFailure() {
- return this == FAILED || this == CANCELED || this == UNKNOWN;
+ return this == FAILED;
}
}