Compare commits
5
Commits
7f8960823b
...
1a7c692e79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7c692e79 | ||
|
|
9bcfe558cf | ||
|
|
2537976722 | ||
|
|
bf2640d8fd | ||
|
|
90eba221d4 |
@@ -23,3 +23,6 @@ htmlcov/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Build artifacts (cross-compiled binaries)
|
||||
spark-mcp-*-*
|
||||
|
||||
@@ -19,9 +19,13 @@ import (
|
||||
// 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) {
|
||||
// HTML page is public — its own modal prompts for the token.
|
||||
// All other /admin/* endpoints (API + OpenAPI docs) still require auth.
|
||||
gPublic := r.Group("/admin")
|
||||
gPublic.GET("", webHandler)
|
||||
gPublic.GET("/", webHandler)
|
||||
|
||||
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))
|
||||
|
||||
@@ -96,6 +96,45 @@ func TestMount_RequiresAuth(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminWeb_NoTokenReturnsHTML(t *testing.T) {
|
||||
r, _, _ := newTestAdmin(t)
|
||||
|
||||
// 1) /admin without token: 200 + HTML
|
||||
w := doReq(t, r, "GET", "/admin", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin without token: got %d, want 200", w.Code)
|
||||
}
|
||||
ct := w.Header().Get("Content-Type")
|
||||
if !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if !strings.Contains(body, "token-modal") {
|
||||
t.Errorf("body missing token-modal element; body excerpt: %.200s", body)
|
||||
}
|
||||
if !strings.Contains(body, "Admin Token Required") {
|
||||
t.Errorf("body missing modal title")
|
||||
}
|
||||
|
||||
// 2) /admin/ (trailing slash) same behavior
|
||||
w2 := doReq(t, r, "GET", "/admin/", "", nil)
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin/ without token: got %d, want 200", w2.Code)
|
||||
}
|
||||
|
||||
// 3) API routes still require auth (no token = 401)
|
||||
w3 := doReq(t, r, "GET", "/admin/clusters", "", nil)
|
||||
if w3.Code != http.StatusUnauthorized {
|
||||
t.Errorf("GET /admin/clusters without token: got %d, want 401", w3.Code)
|
||||
}
|
||||
|
||||
// 4) API routes still require auth (valid token = 200)
|
||||
w4 := doReq(t, r, "GET", "/admin/clusters", "good-token", nil)
|
||||
if w4.Code != http.StatusOK {
|
||||
t.Errorf("GET /admin/clusters with good-token: got %d, want 200", w4.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListClusters(t *testing.T) {
|
||||
r, _, _ := newTestAdmin(t)
|
||||
|
||||
|
||||
+101
-12
@@ -91,15 +91,29 @@
|
||||
.form-actions { display: flex; gap: .5rem; margin-top: 1rem; }
|
||||
.bool { flex-direction: row; align-items: center; gap: .5rem; }
|
||||
.bool input { width: auto; }
|
||||
dialog.modal {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
padding: 1.5rem;
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
dialog.modal::backdrop { background: rgba(0, 0, 0, .65); }
|
||||
dialog.modal h2 { margin: 0 0 .5rem; font-size: 1.1rem; color: var(--accent); }
|
||||
dialog.modal form { display: flex; flex-direction: column; gap: .75rem; }
|
||||
dialog.modal label { color: var(--muted); font-size: .9rem; }
|
||||
dialog.modal input { width: 100%; }
|
||||
.modal-error { color: var(--danger); font-size: .9rem; margin: 0; }
|
||||
.modal-actions { display: flex; gap: .5rem; justify-content: flex-end; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>spark-mcp-go Admin</h1>
|
||||
<div class='auth'>
|
||||
<label for='token'>Token</label>
|
||||
<input id='token' type='password' placeholder='Bearer token' autocomplete='off'>
|
||||
<button id='save-token'>Save</button>
|
||||
<button id='set-token' class='secondary'>Set Token</button>
|
||||
<button id='logout' class='danger'>Logout</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -199,12 +213,25 @@
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<dialog id='token-modal' class='modal'>
|
||||
<h2>Admin Token Required</h2>
|
||||
<p id='modal-error' class='modal-error hidden'></p>
|
||||
<form id='token-form'>
|
||||
<label for='modal-token'>Bearer token</label>
|
||||
<input id='modal-token' type='password' autocomplete='off' required>
|
||||
<div class='modal-actions'>
|
||||
<button type='submit' id='modal-save'>Save</button>
|
||||
<button type='button' id='modal-cancel' class='secondary hidden'>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const API = '/admin';
|
||||
|
||||
const tokenInput = $('#token');
|
||||
tokenInput.value = localStorage.getItem('adminToken') || '';
|
||||
let pendingRetry = null;
|
||||
let cancelBlocker = null;
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
@@ -219,11 +246,67 @@
|
||||
el.classList.toggle('hidden', !msg);
|
||||
}
|
||||
|
||||
function showModalError(msg) {
|
||||
const el = $('#modal-error');
|
||||
el.textContent = msg || '';
|
||||
el.classList.toggle('hidden', !msg);
|
||||
}
|
||||
|
||||
function openTokenModal(opts = {}) {
|
||||
const dialog = $('#token-modal');
|
||||
const cancelBtn = $('#modal-cancel');
|
||||
showModalError(opts.error || '');
|
||||
cancelBtn.classList.toggle('hidden', !opts.allowCancel);
|
||||
if (cancelBlocker) {
|
||||
dialog.removeEventListener('cancel', cancelBlocker);
|
||||
cancelBlocker = null;
|
||||
}
|
||||
if (!opts.allowCancel) {
|
||||
cancelBlocker = (e) => e.preventDefault();
|
||||
dialog.addEventListener('cancel', cancelBlocker);
|
||||
}
|
||||
dialog.showModal();
|
||||
$('#modal-token').focus();
|
||||
}
|
||||
|
||||
function closeTokenModal() {
|
||||
const dialog = $('#token-modal');
|
||||
showModalError('');
|
||||
if (cancelBlocker) {
|
||||
dialog.removeEventListener('cancel', cancelBlocker);
|
||||
cancelBlocker = null;
|
||||
}
|
||||
dialog.close();
|
||||
$('#modal-token').value = '';
|
||||
}
|
||||
|
||||
function saveTokenFromModal(e) {
|
||||
e.preventDefault();
|
||||
localStorage.setItem('adminToken', $('#modal-token').value.trim());
|
||||
closeTokenModal();
|
||||
if (pendingRetry) {
|
||||
const fn = pendingRetry;
|
||||
pendingRetry = null;
|
||||
fn();
|
||||
} else {
|
||||
loadClusters();
|
||||
}
|
||||
}
|
||||
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: headers() };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(API + path, opts);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
localStorage.removeItem('adminToken');
|
||||
pendingRetry = () => api(method, path, body);
|
||||
openTokenModal({
|
||||
error: 'Token rejected by server (401). Enter a valid token.',
|
||||
allowCancel: false
|
||||
});
|
||||
throw new Error('401 Unauthorized');
|
||||
}
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
const j = await res.json();
|
||||
@@ -368,16 +451,18 @@
|
||||
$('#refresh').addEventListener('click', loadClusters);
|
||||
$('#btn-cancel').addEventListener('click', hideForm);
|
||||
|
||||
$('#save-token').addEventListener('click', () => {
|
||||
localStorage.setItem('adminToken', tokenInput.value.trim());
|
||||
loadClusters();
|
||||
});
|
||||
$('#set-token').addEventListener('click', () => openTokenModal({ allowCancel: true }));
|
||||
$('#logout').addEventListener('click', () => {
|
||||
localStorage.removeItem('adminToken');
|
||||
tokenInput.value = '';
|
||||
showError('Token cleared; reload the page after supplying a new token.');
|
||||
openTokenModal({
|
||||
error: 'Token cleared. Enter a new token to continue.',
|
||||
allowCancel: false
|
||||
});
|
||||
});
|
||||
|
||||
$('#token-form').addEventListener('submit', saveTokenFromModal);
|
||||
$('#modal-cancel').addEventListener('click', closeTokenModal);
|
||||
|
||||
$('#cluster-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
showError('');
|
||||
@@ -395,7 +480,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
loadClusters();
|
||||
if (!localStorage.getItem('adminToken')) {
|
||||
openTokenModal({ allowCancel: false });
|
||||
} else {
|
||||
loadClusters();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -41,6 +41,7 @@ func shortHexID(n int) (string, error) {
|
||||
|
||||
type toolCallLogger struct {
|
||||
file *os.File
|
||||
base *slog.Logger
|
||||
toolName string
|
||||
startedAt time.Time
|
||||
resultRaw json.RawMessage
|
||||
@@ -54,6 +55,12 @@ func (t *toolCallLogger) WithResult(result any) {
|
||||
|
||||
func (t *toolCallLogger) WithError(err error) {
|
||||
t.err = err
|
||||
if t.base != nil && err != nil {
|
||||
t.base.Error("tool.call.error",
|
||||
"tool", t.toolName,
|
||||
"err", err.Error(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *toolCallLogger) End() {
|
||||
@@ -82,6 +89,14 @@ func (t *toolCallLogger) End() {
|
||||
}
|
||||
_, _ = t.file.Write(b)
|
||||
_, _ = t.file.WriteString("\n")
|
||||
|
||||
if t.base != nil {
|
||||
t.base.Info("tool.call.end",
|
||||
"tool", t.toolName,
|
||||
"duration_ms", durationMs,
|
||||
"error", errStr,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +160,7 @@ func StartToolCall(ctx context.Context, base *slog.Logger, toolName string, para
|
||||
|
||||
return &toolCallLogger{
|
||||
file: f,
|
||||
base: base,
|
||||
toolName: toolName,
|
||||
startedAt: startedAt,
|
||||
}, nil
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newCapturingLogger returns a slog.Logger that writes JSON to buf and a
|
||||
// pointer to buf so tests can assert on what was emitted.
|
||||
func newCapturingLogger() (*slog.Logger, *bytes.Buffer) {
|
||||
buf := &bytes.Buffer{}
|
||||
return slog.New(slog.NewJSONHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug})), buf
|
||||
}
|
||||
|
||||
// initLogDir sets the package-level logDir for StartToolCall to discover.
|
||||
// Tests run in a temp directory; tear down via t.Cleanup.
|
||||
func initLogDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
logDirMu.Lock()
|
||||
prev := logDir
|
||||
logDir = dir
|
||||
logDirMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
logDirMu.Lock()
|
||||
logDir = prev
|
||||
logDirMu.Unlock()
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
// TestStartToolCall_EmitsErrorAndEnd verifies that WithError and End each
|
||||
// emit a structured event through the main base logger, in addition to
|
||||
// writing the per-tool file. This is what makes "tail -f spark-mcp.log"
|
||||
// useful for debugging tool-call failures.
|
||||
func TestStartToolCall_EmitsErrorAndEnd(t *testing.T) {
|
||||
dir := initLogDir(t)
|
||||
|
||||
base, buf := newCapturingLogger()
|
||||
|
||||
cl, err := StartToolCall(context.Background(), base, "list_applications", map[string]any{"cluster_id": "x"})
|
||||
if err != nil {
|
||||
t.Fatalf("StartToolCall: %v", err)
|
||||
}
|
||||
if cl == nil {
|
||||
t.Fatal("nil logger")
|
||||
}
|
||||
|
||||
cl.WithError(os.ErrNotExist)
|
||||
cl.End()
|
||||
|
||||
// Per-tool file is still written (regression check).
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "tools"))
|
||||
if err != nil {
|
||||
t.Fatalf("read tools dir: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatalf("no per-tool log file written")
|
||||
}
|
||||
|
||||
// Decode each JSON line in the captured buffer.
|
||||
var sawError, sawEnd bool
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(buf.String()), "\n") {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("invalid JSON in log: %q (err=%v)", line, err)
|
||||
}
|
||||
switch rec["msg"] {
|
||||
case "tool.call.error":
|
||||
sawError = true
|
||||
if rec["level"] != "ERROR" {
|
||||
t.Errorf("tool.call.error level=%v, want ERROR", rec["level"])
|
||||
}
|
||||
if rec["tool"] != "list_applications" {
|
||||
t.Errorf("tool.call.error tool=%v, want list_applications", rec["tool"])
|
||||
}
|
||||
if !strings.Contains(rec["err"].(string), "file does not exist") {
|
||||
t.Errorf("tool.call.error err=%v, missing underlying err text", rec["err"])
|
||||
}
|
||||
case "tool.call.end":
|
||||
sawEnd = true
|
||||
if rec["level"] != "INFO" {
|
||||
t.Errorf("tool.call.end level=%v, want INFO", rec["level"])
|
||||
}
|
||||
if rec["tool"] != "list_applications" {
|
||||
t.Errorf("tool.call.end tool=%v, want list_applications", rec["tool"])
|
||||
}
|
||||
if _, ok := rec["duration_ms"].(float64); !ok {
|
||||
t.Errorf("tool.call.end missing numeric duration_ms: %v", rec)
|
||||
}
|
||||
if rec["error"] != "file does not exist" {
|
||||
t.Errorf("tool.call.end error=%v, want file does not exist", rec["error"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !sawError {
|
||||
t.Errorf("expected tool.call.error in main logger; got: %s", buf.String())
|
||||
}
|
||||
if !sawEnd {
|
||||
t.Errorf("expected tool.call.end in main logger; got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartToolCall_NilBaseSafe verifies the noop-friendly path: if base is
|
||||
// nil (e.g. tests where deps.Logger is unset), WithError and End must not
|
||||
// panic and the per-tool file must still be written.
|
||||
func TestStartToolCall_NilBaseSafe(t *testing.T) {
|
||||
dir := initLogDir(t)
|
||||
|
||||
cl, err := StartToolCall(context.Background(), nil, "noop_tool", map[string]any{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatalf("StartToolCall: %v", err)
|
||||
}
|
||||
// Should not panic.
|
||||
cl.WithError(os.ErrPermission)
|
||||
cl.End()
|
||||
|
||||
// Per-tool file still written.
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "tools"))
|
||||
if err != nil {
|
||||
t.Fatalf("read tools dir: %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Errorf("expected per-tool file even with nil base")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartToolCall_SuccessfulCall_EmitsEndOnly verifies that a happy-path
|
||||
// call emits exactly one end event (no error event), with an empty error
|
||||
// string and a non-negative duration.
|
||||
func TestStartToolCall_SuccessfulCall_EmitsEndOnly(t *testing.T) {
|
||||
_ = initLogDir(t)
|
||||
|
||||
base, buf := newCapturingLogger()
|
||||
cl, err := StartToolCall(context.Background(), base, "list_clusters", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("StartToolCall: %v", err)
|
||||
}
|
||||
cl.End()
|
||||
|
||||
lines := strings.SplitSeq(strings.TrimSpace(buf.String()), "\n")
|
||||
// Exactly one event: tool.call.start (from StartToolCall itself).
|
||||
// No tool.call.error, and one tool.call.end.
|
||||
var endCount, errorCount int
|
||||
for line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||
t.Fatalf("invalid JSON: %q", line)
|
||||
}
|
||||
switch rec["msg"] {
|
||||
case "tool.call.end":
|
||||
endCount++
|
||||
if rec["error"] != "" {
|
||||
t.Errorf("successful end should have empty error; got %v", rec["error"])
|
||||
}
|
||||
case "tool.call.error":
|
||||
errorCount++
|
||||
}
|
||||
}
|
||||
if errorCount != 0 {
|
||||
t.Errorf("expected 0 tool.call.error, got %d; buf=%s", errorCount, buf.String())
|
||||
}
|
||||
if endCount != 1 {
|
||||
t.Errorf("expected exactly 1 tool.call.end, got %d; buf=%s", endCount, buf.String())
|
||||
}
|
||||
// Sanity: duration_ms is non-negative.
|
||||
for line := range lines {
|
||||
var rec map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &rec); err == nil {
|
||||
if msg, _ := rec["msg"].(string); msg == "tool.call.end" {
|
||||
if d, ok := rec["duration_ms"].(float64); !ok || d < 0 {
|
||||
t.Errorf("duration_ms invalid: %v", rec["duration_ms"])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensure time is referenced even if no test uses it directly (avoids unused
|
||||
// import churn when tests are edited).
|
||||
var _ = time.Now
|
||||
@@ -15,7 +15,7 @@ const defaultTailBytes = 1 << 20
|
||||
// NewGetApplicationLogsTool returns the schema for the get_application_logs MCP Tool.
|
||||
func NewGetApplicationLogsTool() mcp.Tool {
|
||||
return mcp.NewTool(GetApplicationLogsName,
|
||||
mcp.WithDescription("Fetch YARN application logs. Tries amContainerLogs first, then aggregated-logs, then the legacy /logs endpoint. Returns {source, text}."),
|
||||
mcp.WithDescription("Fetch YARN application logs. Tries amContainerLogs (RM endpoint, often 307 to a NodeManager) first, then aggregated-logs, then the legacy /logs endpoint, and finally the AM container's direct NodeManager URL parsed from the app response. Returns {source, text}."),
|
||||
mcp.WithString("cluster_id",
|
||||
mcp.Required(),
|
||||
mcp.Description("ID of the configured cluster (from list_clusters)"),
|
||||
|
||||
@@ -13,7 +13,7 @@ const GetApplicationStatusName = "get_application_status"
|
||||
// NewGetApplicationStatusTool returns the schema for the get_application_status MCP Tool.
|
||||
func NewGetApplicationStatusTool() mcp.Tool {
|
||||
return mcp.NewTool(GetApplicationStatusName,
|
||||
mcp.WithDescription("Get the detailed status of a single YARN application from the ResourceManager. Returns the raw RM JSON for the app."),
|
||||
mcp.WithDescription("Get the detailed status of a single YARN application from the ResourceManager. Returns the raw RM JSON for the app, including the amContainerLogs field (a direct NodeManager URL usable with fetch_url as a last-resort log source if all get_application_logs fallback endpoints fail)."),
|
||||
mcp.WithString("cluster_id",
|
||||
mcp.Required(),
|
||||
mcp.Description("ID of the configured cluster (from list_clusters)"),
|
||||
|
||||
@@ -24,6 +24,13 @@ func NewListApplicationsTool() mcp.Tool {
|
||||
mcp.WithString("user",
|
||||
mcp.Description("Filter by submitting user."),
|
||||
),
|
||||
mcp.WithString("queue",
|
||||
mcp.Description("Filter by YARN queue name. Matches the YARN RM ?queue= parameter."),
|
||||
),
|
||||
mcp.WithNumber("limit",
|
||||
mcp.Description("Maximum number of applications to return. Defaults to 100 if not provided; set to 0 to request the RM default (no limit)."),
|
||||
mcp.DefaultNumber(100),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,11 +50,24 @@ func (d *Deps) ListApplicationsHandler(ctx context.Context, req mcp.CallToolRequ
|
||||
if v, ok := args["user"].(string); ok {
|
||||
user = v
|
||||
}
|
||||
queue := ""
|
||||
if v, ok := args["queue"].(string); ok {
|
||||
queue = v
|
||||
}
|
||||
limit := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
|
||||
callLog := startToolCall(ctx, d.Logger, ListApplicationsName, map[string]any{
|
||||
"cluster_id": clusterID,
|
||||
"state": state,
|
||||
"user": user,
|
||||
"queue": queue,
|
||||
"limit": limit,
|
||||
})
|
||||
defer callLog.End()
|
||||
|
||||
@@ -58,7 +78,7 @@ func (d *Deps) ListApplicationsHandler(ctx context.Context, req mcp.CallToolRequ
|
||||
}
|
||||
|
||||
rmc := rm.New(d.HTTPClient, cl)
|
||||
raw, err := rmc.ListApps(ctx, state, user)
|
||||
raw, err := rmc.ListApps(ctx, state, user, queue, limit)
|
||||
if err != nil {
|
||||
callLog.WithError(err)
|
||||
return errResult("list_applications: " + err.Error()), nil
|
||||
|
||||
+37
-6
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"spark-mcp-go/internal/cluster"
|
||||
@@ -24,8 +25,8 @@ 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) {
|
||||
// ListApps returns raw JSON from GET /ws/v1/cluster/apps[?state=&user=&queue=&limit=].
|
||||
func (r *Client) ListApps(ctx context.Context, state, user, queue string, limit int) (json.RawMessage, error) {
|
||||
base := r.cluster.RMURL + "/ws/v1/cluster/apps"
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
@@ -38,6 +39,12 @@ func (r *Client) ListApps(ctx context.Context, state, user string) (json.RawMess
|
||||
if user != "" {
|
||||
q.Set("user", user)
|
||||
}
|
||||
if queue != "" {
|
||||
q.Set("queue", queue)
|
||||
}
|
||||
if limit > 0 {
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return r.do(ctx, http.MethodGet, u.String(), nil)
|
||||
}
|
||||
@@ -55,9 +62,10 @@ func (r *Client) KillApp(ctx context.Context, appID string) (json.RawMessage, er
|
||||
}
|
||||
|
||||
// GetLogs walks a fallback chain to retrieve YARN application logs:
|
||||
// 1. GET /ws/v1/cluster/apps/{id}/amContainerLogs (RM often 307 to a NodeManager)
|
||||
// 1. GET /ws/v1/cluster/apps/{id}/amContainerLogs (RM endpoint, often 307 to a NodeManager)
|
||||
// 2. GET /ws/v1/cluster/apps/{id}/aggregated-logs?container=...
|
||||
// 3. GET /ws/v1/cluster/apps/{id}/logs
|
||||
// 4. GET the NodeManager URL from the app's amContainerLogs field.
|
||||
//
|
||||
// The first successful endpoint wins. The returned source names the endpoint
|
||||
// that produced the body.
|
||||
@@ -69,12 +77,31 @@ func (r *Client) GetLogs(ctx context.Context, appID, container string) (body []b
|
||||
}
|
||||
var lastErr error
|
||||
for _, ep := range endpoints {
|
||||
b, e := r.doBytes(ctx, http.MethodGet, r.cluster.RMURL+ep.path, nil)
|
||||
b, e := r.doBytesWithHosts(ctx, http.MethodGet, r.cluster.RMURL+ep.path, nil)
|
||||
if e == nil {
|
||||
return b, ep.name, nil
|
||||
}
|
||||
lastErr = e
|
||||
}
|
||||
// Try fetching the NodeManager's stdout URL directly.
|
||||
app, err := r.GetApp(ctx, appID)
|
||||
if err == nil {
|
||||
var resp struct {
|
||||
App struct {
|
||||
AMContainerLogs string `json:"amContainerLogs"`
|
||||
} `json:"app"`
|
||||
}
|
||||
if err := json.Unmarshal(app, &resp); err == nil && resp.App.AMContainerLogs != "" {
|
||||
nmHost := extractHost(resp.App.AMContainerLogs)
|
||||
b, e := r.doBytesWithHosts(ctx, http.MethodGet, resp.App.AMContainerLogs, nil, nmHost)
|
||||
if e == nil {
|
||||
return b, "amContainerLogs-direct", nil
|
||||
}
|
||||
lastErr = e
|
||||
}
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
return nil, "", fmt.Errorf("rm: all log endpoints failed: %w", lastErr)
|
||||
}
|
||||
|
||||
@@ -92,7 +119,7 @@ func (r *Client) do(ctx context.Context, method, rawURL string, body io.Reader)
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func (r *Client) doBytes(ctx context.Context, method, rawURL string, body io.Reader) ([]byte, error) {
|
||||
func (r *Client) doBytesWithHosts(ctx context.Context, method, rawURL string, body io.Reader, extraHosts ...string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -100,7 +127,7 @@ func (r *Client) doBytes(ctx context.Context, method, rawURL string, body io.Rea
|
||||
if err := httpclient.ApplyAuth(req, "", r.cluster); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := r.hc.DoWithRedirect(ctx, req, 5, r.allowedHosts()...)
|
||||
resp, err := r.hc.DoWithRedirect(ctx, req, 5, append(r.allowedHosts(), extraHosts...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -111,6 +138,10 @@ func (r *Client) doBytes(ctx context.Context, method, rawURL string, body io.Rea
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
func (r *Client) doBytes(ctx context.Context, method, rawURL string, body io.Reader) ([]byte, error) {
|
||||
return r.doBytesWithHosts(ctx, method, rawURL, body)
|
||||
}
|
||||
|
||||
func (r *Client) allowedHosts() []string {
|
||||
hosts := []string{extractHost(r.cluster.RMURL)}
|
||||
for _, pat := range r.cluster.URLAllowlist {
|
||||
|
||||
+116
-4
@@ -3,6 +3,7 @@ package rm
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -30,7 +31,7 @@ func TestListApps(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
raw, err := testClient(srv, cl).ListApps(context.Background(), "", "")
|
||||
raw, err := testClient(srv, cl).ListApps(context.Background(), "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
@@ -54,7 +55,7 @@ func TestListApps_WithQuery(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
raw, err := testClient(srv, cl).ListApps(context.Background(), "RUNNING", "yarn")
|
||||
raw, err := testClient(srv, cl).ListApps(context.Background(), "RUNNING", "yarn", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
@@ -212,7 +213,7 @@ func TestApplyAuth_SimpleUserName(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
cl := &cluster.Cluster{RMURL: srv.URL, AuthType: cluster.AuthSimple, AuthUsername: "alice"}
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "")
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
@@ -227,7 +228,7 @@ func TestApplyAuth_BasicHeader(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
cl := &cluster.Cluster{RMURL: srv.URL, AuthType: cluster.AuthBasic, AuthUsername: "u", AuthPassword: "p"}
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "")
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
@@ -236,3 +237,114 @@ func TestApplyAuth_BasicHeader(t *testing.T) {
|
||||
t.Errorf("Authorization=%q, want %q", captured, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_WithLimit(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("limit") != "50" {
|
||||
t.Errorf("limit=%q, want 50", r.URL.Query().Get("limit"))
|
||||
}
|
||||
w.Write([]byte(`{"apps":{"app":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "", "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_WithQueue(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("queue") != "root.users.alice" {
|
||||
t.Errorf("queue=%q, want root.users.alice", r.URL.Query().Get("queue"))
|
||||
}
|
||||
w.Write([]byte(`{"apps":{"app":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "", "root.users.alice", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListApps_LimitZeroNoParam(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("limit") != "" {
|
||||
t.Errorf("limit should not be sent when 0; got %q", r.URL.Query().Get("limit"))
|
||||
}
|
||||
w.Write([]byte(`{"apps":{"app":[]}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
_, err := testClient(srv, cl).ListApps(context.Background(), "", "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListApps: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogs_FallbackToAMDirect(t *testing.T) {
|
||||
// Mock NM that we want GetLogs to hit after standard endpoints fail.
|
||||
nmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("nm direct logs"))
|
||||
}))
|
||||
defer nmSrv.Close()
|
||||
nmHost := nmSrv.Listener.Addr().String()
|
||||
|
||||
rmSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/amContainerLogs":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/aggregated-logs":
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/logs":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123" && r.Method == http.MethodGet:
|
||||
// GetApp returns amContainerLogs pointing to the mock NM.
|
||||
w.Write([]byte(fmt.Sprintf(`{"app":{"id":"app_123","amContainerLogs":"%s/node/containerlogs/container_1/root"}}`, nmSrv.URL)))
|
||||
default:
|
||||
t.Errorf("unexpected RM path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer rmSrv.Close()
|
||||
|
||||
cl := &cluster.Cluster{
|
||||
RMURL: rmSrv.URL,
|
||||
URLAllowlist: []string{nmHost}, // allow NM host for SSRF
|
||||
}
|
||||
body, source, err := testClient(rmSrv, cl).GetLogs(context.Background(), "app_123", "container_1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetLogs: %v", err)
|
||||
}
|
||||
if source != "amContainerLogs-direct" {
|
||||
t.Errorf("source=%q, want amContainerLogs-direct", source)
|
||||
}
|
||||
if string(body) != "nm direct logs" {
|
||||
t.Errorf("body=%q", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLogs_FallbackFailsWhenAMFieldMissing(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/amContainerLogs":
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/aggregated-logs":
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123/logs":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
case r.URL.Path == "/ws/v1/cluster/apps/app_123" && r.Method == http.MethodGet:
|
||||
// amContainerLogs is empty
|
||||
w.Write([]byte(`{"app":{"id":"app_123"}}`))
|
||||
default:
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cl := &cluster.Cluster{RMURL: srv.URL}
|
||||
_, _, err := testClient(srv, cl).GetLogs(context.Background(), "app_123", "container_1")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when all endpoints fail and amContainerLogs is empty")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user