Commit Graph
17 Commits
Author SHA1 Message Date
tao.chenandClaude 9bcfe558cf logging: tool.call.error/end 接入主 logger (双写终端 + spark-mcp.log)
之前所有 tool 调用的 error 和 end 事件只写进 per-tool 文件
(data/logs/tools/<ts>_<tool>_<id>.log), 主日志文件 spark-mcp.log 完全
看不到。tail -f spark-mcp.log 排查问题时形同虚设, 必须去找具体的
per-tool 文件才能看到错误。

修复: toolCallLogger struct 加 base *slog.Logger 字段, StartToolCall
把传入的 base 写进 struct. WithError 在记录错误的同时调 base.Error
("tool.call.error", tool, name, err), End 在写完 per-tool 文件后
调 base.Info("tool.call.end", tool, name, duration_ms, error).

base 走的是 main logger (multiHandler 包装), 自动同时写终端 (遵循
LOG_FORMAT, 默认 text) 和主日志文件 (永远 JSON). LOG_LEVEL 过滤对
这两条事件仍生效, 行为统一.

新增 3 个单元测试 (internal/logging/tool_call_test.go):
  - TestStartToolCall_EmitsErrorAndEnd: 验证错误路径两条事件都进
    主 logger (用 bytes.Buffer 捕获 slog 输出, 解码 JSON 校验字段)
  - TestStartToolCall_NilBaseSafe: 验证 base=nil (测试场景) 不 panic
    且 per-tool 文件仍写入
  - TestStartToolCall_SuccessfulCall_EmitsEndOnly: 验证成功路径
    只发 end 不发 error, duration_ms >= 0

附带: gofmt 重排 internal/logging/tool_call.go import 块 (按字母序)

未提交: spark-mcp-linux-amd64 (本地 build 产物, .gitignore 未拦,
需另决定); .env (gitignored 内容)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 15:27:28 +08:00
tao.chenandClaude 2537976722 rm: list_applications 加 limit/queue + 日志 fallback 链补 NM 直连
P0 (Python parity 缺口):
  - rm.Client.ListApps 新增 queue + limit 查询参数
  - list_applications MCP tool schema 暴露 queue (string) 和 limit
    (number, default 100), handler 透传给 RM client
  - 限流原因: 生产 RM 上无 limit 会拉回 N MB JSON 撞 MaxResponseBytes
  - limit <= 0 不发送 query 参数, 跟 RM 默认行为一致; 老的调用方
    (TestListApplications_EndToEnd 等) 不传参时行为不变
  - 4 个旧 ListApps 测试调用点跟着更新, 加 3 个新测试:
    TestListApps_WithLimit, TestListApps_WithQueue,
    TestListApps_LimitZeroNoParam

P1.3 (Python fallback 行为补全):
  - rm.Client.GetLogs 加第 4 步 fallback: 当前 3 步
    (amContainerLogs/aggregated-logs/logs) 全失败时, 调 GetApp
    解析 app.amContainerLogs 字段, GET 该 URL 直连 NodeManager
  - 新 source 名 'amContainerLogs-direct' 区分 RM-level endpoint
  - 提取 doBytesWithHosts(extraHosts...) 支持 per-call host 白名单,
    NM host 动态加进 allowedHosts, SSRF 保护不破 (URL 是 RM 响应里
    回来的, 不是 LLM 任意填的)
  - 新增 TestGetLogs_FallbackToAMDirect (成功路径) 和
    TestGetLogs_FallbackFailsWhenAMFieldMissing (amContainerLogs
    字段为空时正常返回 error)

P1.4 (零代码改动 + 文档化):
  - get_application_logs / get_application_status 工具 description
    更新, 提示 LLM raw RM JSON 里包含 amContainerLogs 字段, 可在
    aggregated-logs 全部失败时自己用 fetch_url 直连 NM
  - get_application_status 函数体不变 (本就是透传 raw JSON)

验证:
  - go build / go vet / go test 全部通过
  - 5 个新测试全 PASS, 老的 TestListApplications_EndToEnd 仍 PASS
  - 单 ListApps 调用点 (list_applications.go:81) 编译过

未提交: spark-mcp-linux-amd64 (本地 build 产物, 当前 .gitignore
没拦, 需另行决定)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 15:15:21 +08:00
tao.chenandClaude bf2640d8fd admin: /admin HTML 公开可访问,让 token 弹窗真正能触发
上一轮 (90eba22) 把 admin 页改成 token 弹窗模式,但 webHandler 还
挂在带 AdminAuth 的 group 里。结果无 token 访问 /admin 拿到的不是
HTML,而是 {"error":"missing bearer token"} 的 401 JSON,弹窗
变成死代码。

修复: 拆出 gPublic group,只放 webHandler (GET /admin 和 /admin/)。
API 路由 (/clusters*, /audit) 和 OpenAPI docs (/docs, /docs/spec)
继续受 AdminAuth 保护 (后者暴露内部 API 结构,不该公网可见)。

HTML 自身的 token 输入流不变 — 弹窗仍然在客户端拦截 401、清旧 token、
记录 pendingRetry、保存新 token 后自动重试。

补一个 TestAdminWeb_NoTokenReturnsHTML 锁定新契约:
  - GET /admin 无 token -> 200 + text/html (含 token-modal 元素)
  - GET /admin/ 同上
  - GET /admin/clusters 无 token -> 401 (回归保护)
  - GET /admin/clusters good-token -> 200 (回归保护)

旧 TestMount_RequiresAuth 测的全是 API 路径, 不受影响, 全绿。

验证:
  - go build / go vet / go test 全部通过
  - 端到端 curl 复现用户报告场景: /admin 无 token -> 200 + 弹窗 HTML
  - API + docs 鉴权行为不变

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 14:16:49 +08:00
tao.chenandClaude 90eba221d4 admin: token 输入改为弹窗模式 (modal)
把 header 内嵌的 token 输入框 + Save 按钮拆掉,改用原生 <dialog>
弹窗。三种触发场景: 首访无 token 自动弹、API 401 自动弹 (清旧 token
后弹带错误信息)、用户主动点 Set Token 按钮弹。无有效 token 时阻止
Escape 关闭 (cancel 事件 preventDefault), 防止误操作丢失输入。
localStorage 键名 adminToken 不变, 零迁移成本。401 路径在 api()
内部清 token + 弹窗 + 记录 pendingRetry 闭包, 用户保存新 token 后
自动重试失败请求。

设计取舍:
  - 选原生 <dialog> 而非自造 div + JS, 自带 modal/背板/焦点陷阱/a11y
  - 选 <form> + preventDefault 而非 method='dialog', 避免自动关闭
  - CSS 全部复用现有 dark theme 变量, 0 视觉割裂
  - 0 新依赖 (纯 HTML5 + 原生 JS)

验证:
  - go build / go vet / go test 全部通过
  - 端到端 curl 确认新元素嵌入、旧元素删除、401/200 路径正常
  - router_test.go 无回归 (HTML 改动不涉及 Go)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 14:08:16 +08:00
tao.chenandClaude 7f8960823b config: GIN_MODE 改为环境变量配置 + Load() 自动加载 .env
main.go 硬编码 gin.ReleaseMode 改成读 cfg.GinMode(默认 release),
方便生产/调试切换 debug/release/test。Load() 开头先调 loadDotenv
(".env"): 缺文件静默跳过,shell env 优先于 .env,跳过注释/空行/
无 '=' 行并 slog.Warn。仅用标准库,不引新依赖。

验证:
  - go build / go vet / go test 全部通过
  - shell env 覆盖 .env (LISTEN_ADDR=:19090 覆盖 .env 的 :18080)
  - 仅 .env 时正确加载 (监听 18080, GIN_MODE=debug 输出 [GIN-debug])
  - 无 .env 且无 shell env 时清晰报错 ADMIN_TOKENS is not set

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-13 13:48:35 +08:00
tao.chenandClaude 756a88a800 Linus 修复: PUT 部分更新用 map[string]json.RawMessage 区分 absent/present
原 PUT /admin/clusters/:id 用 ShouldBindJSON + struct, 缺字段自动
填 Go zero value, 无法区分 "未传" vs "传零值":
- true bool → 缺省 = false → 被误翻成 false
- 1 int → 缺省 = 0 → 丢失
- "" string → 缺省 = "" → 清空

第二次 (用 if-zero-value-keep-old 逻辑) 实际更糟: bool "缺省 = false"
跟 old "true" 不等 → 反而把 true 翻成 false。

正确做法: 用 map[string]json.RawMessage 解析 body, 用
_, ok := rawBody["field"] 判断 absent, 缺字段 = 保留 old。

- internal/admin/router.go: 改 updateCluster 解析方式,
  加 decodeString/Int/Bool/StringSlice helpers
  + validateClusterUpdate (轻量校验, 不要求所有必填字段)
- internal/admin/router_test.go: +2 测试
  - TestUpdateCluster_PartialUpdatePreservesOtherFields
    (单字段 PUT 保留其他全部)
  - TestUpdateCluster_SliceReplacement
    (slice 显式覆盖, nil = 保留, [] = 替换)

端到端实测:
- PUT rate_limit=99 保留 is_active=true (修复前会被翻 false)
- PUT is_active=false 保留 rate_limit=99
- PUT auth_type=basic 保留其他全部
- PUT url_allowlist=[only] 替换 slice
- audit 5 条 (1 create + 4 update) DESC 正确

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 18:23:44 +08:00
tao.chenandClaude 9a48f9e68e Phase 7 起步: Admin OpenAPI + Scalar UI + Cluster CRUD HTML
用户问题: 有 list_clusters MCP tool 但没简易 admin HTML。
A + B 方案:
- A: OpenAPI 文档 + Scalar UI(让人看 + 试 admin API)
- B: Cluster CRUD HTML 页面(让人填表创建/改 cluster)

实现:
- internal/admin/openapi.yaml: 手写 6 admin 端点 + Cluster/AuditEntry/Error
  schema。auth_password 不在 spec(因 json:"-" tag)
- internal/admin/openapi.go: //go:embed openapi.yaml + Scalar HTML
  GET /admin/docs 返 Scalar UI (CDN), GET /admin/docs/spec 返 YAML
- internal/admin/web/cluster.html: 单文件 401 行暗色 monospace 风格
  cluster CRUD 页面, 14 字段表单, vanilla JS + localStorage token
  + 内嵌 CSS, 无前端框架, 无 bundler
- internal/admin/web.go: //go:embed web/cluster.html
- internal/admin/router.go: Mount 里追加 4 路由 (GET /admin, /admin/,
  /admin/docs, /admin/docs/spec), 全部在 AdminAuth 组内

Linus 视角决策: 整个 /admin 子树 (含 HTML) 统一用 AdminAuth —
用户进页面前输 token, JS 存 localStorage, 后续 fetch 自动加 header。
简单且一致, 不搞"白名单 + 不带 token 也能看页"这种特殊 case。

Constraint:
- 不引第三方 Go 库 (不用 gofiber/swagger/scalar-go)
- 不引前端框架 (no React/Vue)
- 不开 JS bundler (no webpack/vite)
- HTML/CSS/JS 全内嵌在 cluster.html 一个文件
- Scalar 走 CDN (@scalar/api-reference latest)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 18:17:37 +08:00
tao.chenandClaude 77d8bae503 Linus 修复: 空 list JSON 序列化成 [] + seed.sh curl 续行
Linus 视角问题: ClusterRepo.List / AuditRepo.List 在空 list 时
返 nil, encoding/json 默认序列化成 null, 不是 []。
LLM/前端期望一致空数组语义。

- internal/storage/cluster_repo.go: List 返回前 out = []*cluster.Cluster{}
- internal/audit/repo.go: List 返回前 out = []*Entry{}

scripts/seed.sh curl 续行 bug: 在 set -euo pipefail 下 \\ 续行
被解析成多行, curl 收到混乱 argv 报 "URL rejected: Bad hostname"。
改为单行 + backslash 续行在 bash 中更稳。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 17:24:40 +08:00
tao.chenandClaude d6dd2b846f Phase 6: 部署文档 + 启动脚本 + 配置样例
- README.md (英文): 项目概览 + 11 Tool 表格 + 12 env 变量全表
  + 6 admin 端点 + 安全模型 (3 道防线 + 4 项额外保证) + dev 命令
- .env.example: 12 env 变量 + 默认值 + 必填标注
- scripts/dev.sh: 一键 build + 后台启动 (nohup + PID 文件) + healthz 验证
- scripts/seed.sh: admin POST 一个示例 cluster (从 .env 读 admin token)
- ARCHITECTURE.md (中文): 包依赖图 + 数据流 + 11 Tool 分类 + 关键设计决策
- docs/runbook.md (中文): 部署 + 升级 + 故障排查 (401/404/422 等)
- docs/systemd/spark-mcp-go.service: systemd unit 模板

README 安全模型小修正: 第 3 道防线从"spark_submit flag 白名单"
(实际不存在) 改为 "upload_file 路径白名单", 加 Additional guarantees
小节列 slice form / json:"-" / 跨主机 redirect / per-tool log 权限。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 17:24:19 +08:00
tao.chenandClaude be4ca460c9 Phase 5 Batch 1 (补): mcp/server + 2 Tool (Batch 1 commit 时漏 stage)
上一个 commit (7056657) 漏 add 的 mcp 包文件:
- internal/mcp/server.go (Streamable HTTP Handler)
- internal/mcp/tools/list_clusters.go (发现入口)
- internal/mcp/tools/spark_submit.go (本地 exec 提交)
- internal/mcp/tools/helpers.go (textResult/errResult/encodeJSON)

deps.go / rm.go 已在 Phase 5 Batch 2 一起 commit, 此处不重复。

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 17:24:10 +08:00
tao.chenandClaude e6411b0dc4 Phase 5 Batch 4: analyzer 规则引擎 + 3 个高层 Tool
- internal/analyzer: 纯函数规则引擎
  - Finding / Severity / Thresholds / Input / StageMetric / ExecutorMetric
  - 3 规则: data_skew (max/min ratio), gc_pressure (GC/CPU ratio),
    bottleneck (shuffle read+write GB)
  - 严重度阶梯: warning / critical (阈值 ×2)
  - Analyze 入口数据驱动 for ... range
  - 11 个单测 (3 规则 × normal/warning/critical + 零除防御)
- fetch_spark_metrics: SHS 业务封装, format=summary 触发 analyzer
- fetch_cluster_env: RM /cluster/info + /cluster/metrics 聚合
- analyze_spark_log: RM 日志降级链 + LLM-Ready Prompt 拼装
  - prompt 结构: cluster 元信息 + log source + log tail +
    heuristic findings + suggested LLM analysis
  - findings 是 first-pass filter, LLM 做最终判断
- deps.go: +AnalyzerThresholds
- main.go: 注入 cfg 的 3 个阈值
- 端到端实测: analyze_spark_log 完整 prompt 渲染

完成 11 个 Tool 表面 (list_clusters / spark_submit / 4 RM /
fetch_spark_metrics / fetch_cluster_env / analyze_spark_log /
fetch_url / upload_file)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 16:53:47 +08:00
tao.chenandClaude d4ad97f595 Phase 5 Batch 3: fetch_url + upload_file Tool (底层原语)
- fetch_url: 通用 HTTP 原语
  - host allowlist 校验 (cluster.URLAllowlist + RM/SHS host 兜底)
  - 复用 httpclient.ApplyAuth + DoWithRedirect
  - mcp.WithEnum 约束 method
  - 单元测试: 9 子测试 (basic auth 透传 + 跨主机 redirect 保留 auth)
- upload_file: 本地文件存储 (供 spark_submit 引用)
  - filename sanitize: [a-zA-Z0-9._-], 拒绝路径逃逸
  - filepath.Base 二次校验
  - 写到 <DataDir>/uploads/<name>, 权限 0640
  - 单元测试: 路径遍历全部拒绝
- deps.go: +DataDir
- main.go: 注入 cfg.DataDir
- 端到端实测: upload_file + spark_submit 引用上传脚本

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 16:52:10 +08:00
tao.chenandClaude c4ac3cc354 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>
2026-07-10 16:50:27 +08:00
tao.chenandClaude 705665745e Phase 5 Batch 1: executor + list_clusters + spark_submit + /mcp 路由
- 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>
2026-07-10 16:50:02 +08:00
tao.chenandClaude 2c39091706 Phase 3+4: 共享 HTTP 客户端 + admin API + audit log
Phase 3 (httpclient):
- shared Client (Timeout + MaxResponseBytes 截断)
- SSRF: 16 段私网 CIDR (含 169.254/::ffff:0:0/96 IPv4-mapped)
  + hostname allowlist (精确/后缀/*. 通配)
  + scheme 校验 (仅 http/https)
- auth 适配器: none / simple (YARN user.name) / basic (SetBasicAuth)
- DoWithRedirect: 跨主机跳转保留 Authorization, max 5 默认
- 19 个测试全绿 (httptest 模拟)

Phase 4 (admin + audit):
- internal/audit: Entry + Repo.Insert/List + MarshalDetails, snake_case JSON
- internal/middleware: AdminAuth/AgentAuth (constant-time 比对)
- internal/admin: 6 端点 (GET/POST/PUT/DELETE /admin/clusters, GET /admin/audit)
  + 写操作触发 audit_log (含 before/after diff)
  + AuthPassword 空=保留旧密码
  + 校验: 必填字段 + auth_type 枚举 + rate_limit>=0
- main.go: 挂载 storage.Open + admin.Mount
- L fix: audit.Entry 加 json tag (smoke test 发现大写 key 不规范)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:54:24 +08:00
tao.chenandClaude a4e2472716 Phase 0+1+2 续: 项目骨架 + 配置 + 日志 + 存储层
Phase 0 (项目骨架):
- main.go 改为最小骨架 (config + logging + gin + /healthz + 优雅退出)
- internal/{config,logging,storage,cluster,...}/ 目录占位

Phase 1 (配置 + 日志):
- internal/config: env 解析 + 必填校验 + token 隐藏的 String()
- internal/logging: slog multi-handler 双输出 (终端 text + 主文件 JSON)
  + StartToolCall per-tool 独立文件 (0600), tools 目录 0700

Phase 2 续 (存储层):
- internal/cluster: 16 字段 Cluster struct (AuthPassword json:"-")
- internal/storage: modernc.org/sqlite 接入, WAL 模式, schema 自动迁移
- ClusterRepo CRUD: Create/Get/List/Update/Delete + ErrNotFound
  + AuthPassword 空字符串 = 保留旧密码 (核心约定)
- 7 个单元测试全绿 (:memory: DB)
- created_at/updated_at 改纳秒精度, 消除 List 测试的 sleep 特殊 case

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 12:12:53 +08:00
tao.chen 1dfef0f598 init 2026-07-10 10:19:28 +08:00