- internal/executor: spark-submit 进程执行, exec.Command 切片形式
(绝不 shell) + stdout 10MiB tail-cap + app_id regex
+ 5 个单测覆盖 slice-form 注入防御 + 退出码
- internal/mcp/server.go: NewServer + Streamable HTTP Handler
- internal/mcp/tools/{list_clusters, spark_submit, deps, helpers}.go
- mcp-go API 实测修正: WithArray 用 Items() 声明 item 类型;
StreamableHTTPServer 直接实现 http.Handler, 无 .Handler() 方法
- main.go: 挂 /mcp 路由 (AgentAuth + mcpHandler 手动组合)
Co-Authored-By: Claude <noreply@anthropic.com>
105 lines
3.0 KiB
Go
105 lines
3.0 KiB
Go
package executor
|
|
|
|
import (
|
|
"context"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestRun_BinaryEmpty covers the ErrBinaryEmpty path. The agent-facing
|
|
// behavior is "list the cluster, fix the binary field" — we just guard the
|
|
// error here.
|
|
func TestRun_BinaryEmpty(t *testing.T) {
|
|
_, err := Run(context.Background(), SparkSubmitOpts{Binary: "", Args: []string{"--help"}})
|
|
if err == nil || !strings.Contains(err.Error(), "empty") {
|
|
t.Fatalf("want empty-binary error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRun_FakeBinary uses /bin/echo as a stand-in for spark-submit. We
|
|
// verify slice-form arg passing (no shell) by including an arg that would
|
|
// be catastrophic if interpreted by a shell.
|
|
func TestRun_FakeBinary(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses unix binary")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
|
|
res, err := Run(ctx, SparkSubmitOpts{
|
|
Binary: "/bin/echo",
|
|
Args: []string{"--master", "yarn; rm -rf /tmp/this-should-not-exist"},
|
|
Timeout: 3 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
if res.ExitCode != 0 {
|
|
t.Fatalf("ExitCode: got %d, want 0", res.ExitCode)
|
|
}
|
|
// The arg must be passed through verbatim, not interpreted by a shell.
|
|
if !strings.Contains(res.StdoutTail, "yarn; rm -rf /tmp/this-should-not-exist") {
|
|
t.Errorf("stdout missing literal arg, got: %q", res.StdoutTail)
|
|
}
|
|
if res.AppID != "" {
|
|
t.Errorf("AppID should be empty for echo, got %q", res.AppID)
|
|
}
|
|
}
|
|
|
|
// TestRun_NonZeroExit captures the exit code when the binary returns !=0.
|
|
func TestRun_NonZeroExit(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("uses unix binary")
|
|
}
|
|
res, err := Run(context.Background(), SparkSubmitOpts{
|
|
Binary: "/bin/sh",
|
|
Args: []string{"-c", "exit 7"},
|
|
Timeout: 3 * time.Second,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("want non-nil error from non-zero exit")
|
|
}
|
|
if res.ExitCode != 7 {
|
|
t.Errorf("ExitCode: got %d, want 7", res.ExitCode)
|
|
}
|
|
}
|
|
|
|
// TestMatchAppID locks down the regex so a YARN output tweak doesn't
|
|
// silently break app_id extraction.
|
|
func TestMatchAppID(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"Submitted application application_12345_0001", "application_12345_0001"},
|
|
{"... some prefix application_999_42 ... tail", "application_999_42"},
|
|
{"no app id here", ""},
|
|
{"", ""},
|
|
{"application_1_2 application_3_4", "application_1_2"}, // first match
|
|
}
|
|
for _, tc := range cases {
|
|
got := matchAppID(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("matchAppID(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestCapBuf ensures the tail-capping behavior: writing more than `cap`
|
|
// bytes keeps the most recent `cap` bytes.
|
|
func TestCapBuf(t *testing.T) {
|
|
b := &capBuf{cap: 10}
|
|
// Write 20 bytes total in two chunks.
|
|
if _, err := b.Write([]byte("0123456789")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := b.Write([]byte("ABCDEFGHIJ")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Tail should be the last 10 bytes: "ABCDEFGHIJ".
|
|
if got := b.String(); got != "ABCDEFGHIJ" {
|
|
t.Errorf("capBuf tail: got %q, want %q", got, "ABCDEFGHIJ")
|
|
}
|
|
}
|