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 + + + +
+

spark-mcp-go Admin

+
+ + + + +
+
+ +
+ + +
+ + +
+ + + +
+ + + + + + + + + + + + + + +
IDNameRM URLActiveAuthActions
Loading...
+
+
+ + + +