diff --git a/internal/admin/openapi.go b/internal/admin/openapi.go
new file mode 100644
index 0000000..09d5fca
--- /dev/null
+++ b/internal/admin/openapi.go
@@ -0,0 +1,52 @@
+package admin
+
+import (
+ "embed"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed openapi.yaml
+var openAPIFS embed.FS
+
+// OpenAPIYAML returns the embedded OpenAPI spec bytes.
+func OpenAPIYAML() ([]byte, error) {
+ return openAPIFS.ReadFile("openapi.yaml")
+}
+
+// DocsHandler serves the Scalar API reference HTML.
+func DocsHandler(c *gin.Context) {
+ _, err := OpenAPIYAML()
+ if err != nil {
+ c.String(http.StatusInternalServerError, "openapi.yaml not embedded: %v", err)
+ return
+ }
+ c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(scalarHTML))
+}
+
+// OpenAPISpecHandler serves the raw OpenAPI YAML spec.
+func OpenAPISpecHandler(c *gin.Context) {
+ spec, err := OpenAPIYAML()
+ if err != nil {
+ c.String(http.StatusInternalServerError, "openapi.yaml: %v", err)
+ return
+ }
+ c.Data(http.StatusOK, "application/yaml", spec)
+}
+
+const scalarHTML = `
+
+
+ spark-mcp-go Admin API
+
+
+
+
+
+
+
+
+`
diff --git a/internal/admin/openapi.yaml b/internal/admin/openapi.yaml
new file mode 100644
index 0000000..a88f2a6
--- /dev/null
+++ b/internal/admin/openapi.yaml
@@ -0,0 +1,129 @@
+openapi: 3.0.3
+info:
+ title: spark-mcp-go Admin API
+ version: 0.0.0
+ description: |
+ Admin API for cluster and audit log configuration. All endpoints
+ require `Authorization: Bearer ` (matches any token in
+ the comma-separated ADMIN_TOKENS env var).
+servers:
+ - url: http://localhost:8080
+ description: Default local dev
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ schemas:
+ Cluster:
+ type: object
+ required: [id, name, rm_url, shs_url, spark_submit_execute_bin]
+ properties:
+ id: { type: string, example: prod }
+ name: { type: string, example: Production }
+ rm_url: { type: string, format: uri, example: http://rm:8088 }
+ shs_url: { type: string, format: uri, example: http://shs:18080 }
+ spark_submit_execute_bin:
+ type: string
+ example: /opt/spark/bin/spark-submit
+ description: Absolute path to spark-submit binary
+ is_active: { type: boolean, default: true }
+ auth_type:
+ type: string
+ enum: [none, simple, basic]
+ default: none
+ auth_username: { type: string, description: 'simple: user.name, basic: username' }
+ ssl_verify: { type: boolean, default: true }
+ ssl_ca_bundle: { type: string }
+ url_allowlist:
+ type: array
+ items: { type: string }
+ description: Additional host patterns (besides RM/SHS) permitted for fetch_url
+ default_submit_args:
+ type: array
+ items: { type: string }
+ description: Prepended to every spark_submit args
+ rate_limit_per_min:
+ type: integer
+ default: 10
+ minimum: 0
+ created_at: { type: string, format: date-time }
+ updated_at: { type: string, format: date-time }
+ AuditEntry:
+ type: object
+ properties:
+ id: { type: integer }
+ timestamp: { type: string, format: date-time }
+ actor: { type: string, example: 'admin:secret-a' }
+ action: { type: string, enum: [cluster.create, cluster.update, cluster.delete] }
+ cluster_id: { type: string, nullable: true }
+ details: { type: string, description: 'JSON-encoded before/after diff' }
+ Error:
+ type: object
+ properties:
+ error: { type: string }
+security:
+ - bearerAuth: []
+paths:
+ /admin/clusters:
+ get:
+ summary: List all clusters
+ responses:
+ '200':
+ description: Array of clusters (always [] even if empty)
+ content:
+ application/json:
+ schema:
+ type: array
+ items: { $ref: '#/components/schemas/Cluster' }
+ post:
+ summary: Create a cluster
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Cluster' }
+ responses:
+ '201': { description: Created, content: { application/json: { schema: { $ref: '#/components/schemas/Cluster' } } } }
+ '400': { description: Validation error, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
+ /admin/clusters/{id}:
+ parameters:
+ - name: id
+ in: path
+ required: true
+ schema: { type: string }
+ get:
+ summary: Get a single cluster
+ responses:
+ '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/Cluster' } } } }
+ '404': { description: Not found }
+ put:
+ summary: Update a cluster (password in body is ignored; set via dedicated endpoint)
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema: { $ref: '#/components/schemas/Cluster' }
+ responses:
+ '200': { description: OK }
+ '404': { description: Not found }
+ delete:
+ summary: Delete a cluster
+ responses:
+ '204': { description: Deleted }
+ '404': { description: Not found }
+ /admin/audit:
+ get:
+ summary: Query audit log (most recent first)
+ parameters:
+ - name: limit
+ in: query
+ schema: { type: integer, default: 100, minimum: 1, maximum: 1000 }
+ responses:
+ '200':
+ description: Array of audit entries
+ content:
+ application/json:
+ schema:
+ type: array
+ items: { $ref: '#/components/schemas/AuditEntry' }
diff --git a/internal/admin/router.go b/internal/admin/router.go
index 104bca5..43411e8 100644
--- a/internal/admin/router.go
+++ b/internal/admin/router.go
@@ -19,12 +19,16 @@ import (
// 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 {
diff --git a/internal/admin/web.go b/internal/admin/web.go
new file mode 100644
index 0000000..515776f
--- /dev/null
+++ b/internal/admin/web.go
@@ -0,0 +1,20 @@
+package admin
+
+import (
+ "embed"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+)
+
+//go:embed web/cluster.html
+var webFS embed.FS
+
+func webHandler(c *gin.Context) {
+ data, err := webFS.ReadFile("web/cluster.html")
+ if err != nil {
+ c.String(http.StatusInternalServerError, "cluster.html: %v", err)
+ return
+ }
+ c.Data(http.StatusOK, "text/html; charset=utf-8", data)
+}
diff --git a/internal/admin/web/cluster.html b/internal/admin/web/cluster.html
new file mode 100644
index 0000000..b17c95c
--- /dev/null
+++ b/internal/admin/web/cluster.html
@@ -0,0 +1,401 @@
+
+
+
+
+
+ spark-mcp-go Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | ID |
+ Name |
+ RM URL |
+ Active |
+ Auth |
+ Actions |
+
+
+
+ | Loading... |
+
+
+
+
+
+
+
+