Files
spark-mcp/internal/admin/router.go
T
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

256 lines
6.7 KiB
Go

// Package admin exposes the admin HTTP API for cluster management and audit
// log access. All routes are protected by middleware.AdminAuth.
package admin
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"spark-mcp-go/internal/audit"
"spark-mcp-go/internal/cluster"
"spark-mcp-go/internal/middleware"
"spark-mcp-go/internal/storage"
)
// Mount attaches the /admin sub-router to r, protecting every route with
// bearer-token admin authentication.
func Mount(r *gin.Engine, repo *storage.ClusterRepo, auditRepo *audit.Repo, adminTokens []string) {
g := r.Group("/admin", middleware.AdminAuth(adminTokens))
g.GET("", webHandler)
g.GET("/", webHandler)
g.GET("/clusters", listClusters(repo))
g.POST("/clusters", createCluster(repo, auditRepo))
g.GET("/clusters/:id", getCluster(repo))
g.PUT("/clusters/:id", updateCluster(repo, auditRepo))
g.DELETE("/clusters/:id", deleteCluster(repo, auditRepo))
g.GET("/audit", listAudit(auditRepo))
g.GET("/docs", DocsHandler)
g.GET("/docs/spec", OpenAPISpecHandler)
}
func listClusters(repo *storage.ClusterRepo) gin.HandlerFunc {
return func(c *gin.Context) {
clusters, err := repo.List(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, clusters)
}
}
func createCluster(repo *storage.ClusterRepo, auditRepo *audit.Repo) gin.HandlerFunc {
return func(c *gin.Context) {
var cl cluster.Cluster
if err := c.ShouldBindJSON(&cl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := validateClusterCreate(&cl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
ctx := c.Request.Context()
if err := repo.Create(ctx, &cl); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
details, err := audit.MarshalDetails(clusterFields(&cl))
if err == nil {
_ = auditRepo.Insert(ctx, &audit.Entry{
Actor: actor(c),
Action: audit.ActionClusterCreate,
ClusterID: cl.ID,
Details: details,
})
}
c.JSON(http.StatusCreated, &cl)
}
}
func getCluster(repo *storage.ClusterRepo) gin.HandlerFunc {
return func(c *gin.Context) {
cl, err := repo.Get(c.Request.Context(), c.Param("id"))
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, cl)
}
}
func updateCluster(repo *storage.ClusterRepo, auditRepo *audit.Repo) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
old, err := repo.Get(ctx, id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var cl cluster.Cluster
if err := c.ShouldBindJSON(&cl); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cl.ID = id
if cl.AuthPassword == "" {
cl.AuthPassword = old.AuthPassword
}
if err := repo.Update(ctx, &cl); err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
details, err := audit.MarshalDetails(map[string]any{
"before": clusterFields(old),
"after": clusterFields(&cl),
})
if err == nil {
_ = auditRepo.Insert(ctx, &audit.Entry{
Actor: actor(c),
Action: audit.ActionClusterUpdate,
ClusterID: cl.ID,
Details: details,
})
}
c.JSON(http.StatusOK, &cl)
}
}
func deleteCluster(repo *storage.ClusterRepo, auditRepo *audit.Repo) gin.HandlerFunc {
return func(c *gin.Context) {
id := c.Param("id")
ctx := c.Request.Context()
old, err := repo.Get(ctx, id)
if err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := repo.Delete(ctx, id); err != nil {
if errors.Is(err, storage.ErrNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "cluster not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
details, err := audit.MarshalDetails(map[string]any{
"id": id,
"name": old.Name,
})
if err == nil {
_ = auditRepo.Insert(ctx, &audit.Entry{
Actor: actor(c),
Action: audit.ActionClusterDelete,
ClusterID: id,
Details: details,
})
}
c.Status(http.StatusNoContent)
}
}
func listAudit(auditRepo *audit.Repo) gin.HandlerFunc {
return func(c *gin.Context) {
limitStr := c.DefaultQuery("limit", "100")
limit, err := strconv.Atoi(limitStr)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
entries, err := auditRepo.List(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, entries)
}
}
func actor(c *gin.Context) string {
tok, _ := c.Get("admin_token")
s, _ := tok.(string)
if len(s) > 8 {
s = s[:8]
}
return "admin:" + s
}
func validateClusterCreate(c *cluster.Cluster) error {
if c.ID == "" {
return errors.New("id is required")
}
if c.Name == "" {
return errors.New("name is required")
}
if c.RMURL == "" {
return errors.New("rm_url is required")
}
if c.SHSURL == "" {
return errors.New("shs_url is required")
}
if c.SparkSubmitExecuteBin == "" {
return errors.New("spark_submit_execute_bin is required")
}
switch c.AuthType {
case cluster.AuthNone, cluster.AuthSimple, cluster.AuthBasic:
default:
return errors.New("auth_type must be one of none, simple, basic")
}
if c.RateLimitPerMin < 0 {
return errors.New("rate_limit_per_min must be >= 0")
}
return nil
}
func clusterFields(c *cluster.Cluster) map[string]any {
return map[string]any{
"id": c.ID,
"name": c.Name,
"rm_url": c.RMURL,
"shs_url": c.SHSURL,
"spark_submit_execute_bin": c.SparkSubmitExecuteBin,
"is_active": c.IsActive,
"auth_type": c.AuthType,
"auth_username": c.AuthUsername,
"ssl_verify": c.SSLVerify,
"ssl_ca_bundle": c.SSLCABundle,
"url_allowlist": c.URLAllowlist,
"default_submit_args": c.DefaultSubmitArgs,
"rate_limit_per_min": c.RateLimitPerMin,
}
}