Phase 5 Batch 2: 4 RM Tool + rm 客户端 + 日志降级链

- internal/rm/client.go: YARN RM 客户端
  - ListApps / GetApp / KillApp / GetLogs
  - GetLogs 降级链数据驱动 (amContainerLogs → aggregated-logs → logs)
  - 跨主机 redirect 保留 Authorization (URLAllowlist 兜底)
- 4 Tool: list_applications / get_application_status /
  get_application_logs / kill_application
  - mcp.WithEnum(state) 约束 YARN app 状态
  - tool handler 永远 (result, nil), 业务错误用 NewToolResultError
- deps.go: +HTTPClient + MaxResponseBytes
- main.go: 注入 httpclient.Client
- 端到端实测: mock RM 验证 4 Tool + 降级链 + 错误路径

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
tao.chen
2026-07-10 16:50:27 +08:00
co-authored by Claude
parent 705665745e
commit c4ac3cc354
9 changed files with 885 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
package rm
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"spark-mcp-go/internal/cluster"
"spark-mcp-go/internal/httpclient"
)
// Client is a YARN ResourceManager REST API client bound to a single cluster.
type Client struct {
hc *httpclient.Client
cluster *cluster.Cluster
}
// New creates a ResourceManager client bound to c.
func New(hc *httpclient.Client, c *cluster.Cluster) *Client {
return &Client{hc: hc, cluster: c}
}
// ListApps returns raw JSON from GET /ws/v1/cluster/apps[?state=&user=].
func (r *Client) ListApps(ctx context.Context, state, user string) (json.RawMessage, error) {
base := r.cluster.RMURL + "/ws/v1/cluster/apps"
u, err := url.Parse(base)
if err != nil {
return nil, err
}
q := u.Query()
if state != "" {
q.Set("state", state)
}
if user != "" {
q.Set("user", user)
}
u.RawQuery = q.Encode()
return r.do(ctx, http.MethodGet, u.String(), nil)
}
// GetApp returns raw JSON from GET /ws/v1/cluster/apps/{id}.
func (r *Client) GetApp(ctx context.Context, appID string) (json.RawMessage, error) {
return r.do(ctx, http.MethodGet, r.cluster.RMURL+"/ws/v1/cluster/apps/"+appID, nil)
}
// KillApp PUT {"state":"KILLED"} to /ws/v1/cluster/apps/{id}/state.
func (r *Client) KillApp(ctx context.Context, appID string) (json.RawMessage, error) {
body := `{"state":"KILLED"}`
return r.do(ctx, http.MethodPut, r.cluster.RMURL+"/ws/v1/cluster/apps/"+appID+"/state",
strings.NewReader(body))
}
// GetLogs walks a fallback chain to retrieve YARN application logs:
// 1. GET /ws/v1/cluster/apps/{id}/amContainerLogs (RM often 307 to a NodeManager)
// 2. GET /ws/v1/cluster/apps/{id}/aggregated-logs?container=...
// 3. GET /ws/v1/cluster/apps/{id}/logs
//
// The first successful endpoint wins. The returned source names the endpoint
// that produced the body.
func (r *Client) GetLogs(ctx context.Context, appID, container string) (body []byte, source string, err error) {
endpoints := []struct{ path, name string }{
{fmt.Sprintf("/ws/v1/cluster/apps/%s/amContainerLogs", appID), "amContainerLogs"},
{fmt.Sprintf("/ws/v1/cluster/apps/%s/aggregated-logs?container=%s", appID, url.QueryEscape(container)), "aggregated-logs"},
{fmt.Sprintf("/ws/v1/cluster/apps/%s/logs", appID), "logs"},
}
var lastErr error
for _, ep := range endpoints {
b, e := r.doBytes(ctx, http.MethodGet, r.cluster.RMURL+ep.path, nil)
if e == nil {
return b, ep.name, nil
}
lastErr = e
}
return nil, "", fmt.Errorf("rm: all log endpoints failed: %w", lastErr)
}
// do executes a single HTTP request with auth and allowed-host exemptions,
// returning the validated JSON body.
func (r *Client) do(ctx context.Context, method, rawURL string, body io.Reader) (json.RawMessage, error) {
b, err := r.doBytes(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
raw := json.RawMessage(b)
if !json.Valid(raw) {
return nil, fmt.Errorf("rm: invalid JSON response from %s", rawURL)
}
return raw, nil
}
func (r *Client) doBytes(ctx context.Context, method, rawURL string, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, err
}
if err := httpclient.ApplyAuth(req, "", r.cluster); err != nil {
return nil, err
}
resp, err := r.hc.DoWithRedirect(ctx, req, 5, r.allowedHosts()...)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("rm: %s %s returned %d", method, rawURL, resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func (r *Client) allowedHosts() []string {
hosts := []string{extractHost(r.cluster.RMURL)}
for _, pat := range r.cluster.URLAllowlist {
if pat == "" {
continue
}
hosts = append(hosts, pat)
}
return hosts
}
func extractHost(u string) string {
parsed, err := url.Parse(u)
if err != nil {
return ""
}
return parsed.Hostname()
}
+238
View File
@@ -0,0 +1,238 @@
package rm
import (
"context"
"encoding/base64"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"spark-mcp-go/internal/cluster"
"spark-mcp-go/internal/httpclient"
)
func testClient(srv *httptest.Server, c *cluster.Cluster) *Client {
hc := httpclient.New(httpclient.Config{Timeout: 5 * time.Second, MaxResponseBytes: 1 << 20})
return New(hc, c)
}
func TestListApps(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ws/v1/cluster/apps" {
t.Errorf("path=%q, want /ws/v1/cluster/apps", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"apps":{"app":[]}}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
raw, err := testClient(srv, cl).ListApps(context.Background(), "", "")
if err != nil {
t.Fatalf("ListApps: %v", err)
}
if string(raw) != `{"apps":{"app":[]}}` {
t.Errorf("body=%q", string(raw))
}
}
func TestListApps_WithQuery(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("state") != "RUNNING" {
t.Errorf("state=%q, want RUNNING", q.Get("state"))
}
if q.Get("user") != "yarn" {
t.Errorf("user=%q, want yarn", q.Get("user"))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"apps":{"app":[{"id":"app_1"}]}}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
raw, err := testClient(srv, cl).ListApps(context.Background(), "RUNNING", "yarn")
if err != nil {
t.Fatalf("ListApps: %v", err)
}
if !strings.Contains(string(raw), "app_1") {
t.Errorf("body=%q", string(raw))
}
}
func TestGetApp(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ws/v1/cluster/apps/app_123" {
t.Errorf("path=%q", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"app":{"id":"app_123","state":"RUNNING"}}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
raw, err := testClient(srv, cl).GetApp(context.Background(), "app_123")
if err != nil {
t.Fatalf("GetApp: %v", err)
}
if !strings.Contains(string(raw), "RUNNING") {
t.Errorf("body=%q", string(raw))
}
}
func TestKillApp(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/ws/v1/cluster/apps/app_123/state" {
t.Errorf("path=%q", r.URL.Path)
}
if r.Method != http.MethodPut {
t.Errorf("method=%q, want PUT", r.Method)
}
body, _ := io.ReadAll(r.Body)
if string(body) != `{"state":"KILLED"}` {
t.Errorf("body=%q", string(body))
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"state":"KILLED"}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
raw, err := testClient(srv, cl).KillApp(context.Background(), "app_123")
if err != nil {
t.Fatalf("KillApp: %v", err)
}
if string(raw) != `{"state":"KILLED"}` {
t.Errorf("body=%q", string(raw))
}
}
func TestGetLogs_PrimarySuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ws/v1/cluster/apps/app_123/amContainerLogs" {
w.Write([]byte("am logs"))
return
}
t.Errorf("unexpected path %q", r.URL.Path)
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
body, source, err := testClient(srv, cl).GetLogs(context.Background(), "app_123", "container_1")
if err != nil {
t.Fatalf("GetLogs: %v", err)
}
if source != "amContainerLogs" {
t.Errorf("source=%q, want amContainerLogs", source)
}
if string(body) != "am logs" {
t.Errorf("body=%q", string(body))
}
}
func TestGetLogs_PrimaryRedirects(t *testing.T) {
srvNM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if auth == "" {
t.Error("Authorization header missing after redirect")
}
w.Write([]byte("nm logs"))
}))
defer srvNM.Close()
srvRM := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/ws/v1/cluster/apps/app_123/amContainerLogs" {
w.Header().Set("Location", srvNM.URL+"/node/containerlogs/container_1/root")
w.WriteHeader(http.StatusTemporaryRedirect)
return
}
t.Errorf("unexpected RM path %q", r.URL.Path)
}))
defer srvRM.Close()
nmHost := srvNM.Listener.Addr().String()
cl := &cluster.Cluster{
RMURL: srvRM.URL,
AuthType: cluster.AuthBasic,
AuthUsername: "u",
AuthPassword: "p",
URLAllowlist: []string{nmHost},
}
body, source, err := testClient(srvRM, cl).GetLogs(context.Background(), "app_123", "container_1")
if err != nil {
t.Fatalf("GetLogs: %v", err)
}
if source != "amContainerLogs" {
t.Errorf("source=%q, want amContainerLogs", source)
}
if string(body) != "nm logs" {
t.Errorf("body=%q", string(body))
}
}
func TestGetLogs_FallbackToAggregated(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/ws/v1/cluster/apps/app_123/amContainerLogs":
w.WriteHeader(http.StatusInternalServerError)
case "/ws/v1/cluster/apps/app_123/aggregated-logs":
if r.URL.Query().Get("container") != "container_1" {
t.Errorf("container=%q, want container_1", r.URL.Query().Get("container"))
}
w.Write([]byte("aggregated logs"))
default:
t.Errorf("unexpected path %q", r.URL.Path)
}
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL}
body, source, err := testClient(srv, cl).GetLogs(context.Background(), "app_123", "container_1")
if err != nil {
t.Fatalf("GetLogs: %v", err)
}
if source != "aggregated-logs" {
t.Errorf("source=%q, want aggregated-logs", source)
}
if string(body) != "aggregated logs" {
t.Errorf("body=%q", string(body))
}
}
func TestApplyAuth_SimpleUserName(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("user.name"); got != "alice" {
t.Errorf("user.name=%q, want alice", got)
}
w.Write([]byte(`{"apps":{"app":[]}}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL, AuthType: cluster.AuthSimple, AuthUsername: "alice"}
_, err := testClient(srv, cl).ListApps(context.Background(), "", "")
if err != nil {
t.Fatalf("ListApps: %v", err)
}
}
func TestApplyAuth_BasicHeader(t *testing.T) {
captured := ""
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
captured = r.Header.Get("Authorization")
w.Write([]byte(`{"apps":{"app":[]}}`))
}))
defer srv.Close()
cl := &cluster.Cluster{RMURL: srv.URL, AuthType: cluster.AuthBasic, AuthUsername: "u", AuthPassword: "p"}
_, err := testClient(srv, cl).ListApps(context.Background(), "", "")
if err != nil {
t.Fatalf("ListApps: %v", err)
}
want := "Basic " + base64.StdEncoding.EncodeToString([]byte("u:p"))
if captured != want {
t.Errorf("Authorization=%q, want %q", captured, want)
}
}